first commit
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import 'json_util.dart';
|
||||
|
||||
/// 账户信息(对应后端 AccountResponse)
|
||||
///
|
||||
/// 语义说明(重要):
|
||||
/// - [balance] 是后端计算出来的:`initialBalance + totalIncome - totalExpense`
|
||||
/// - [initialBalance] 才是可写入数据库的字段(后端 PUT /accounts/balance 改的是它)
|
||||
/// - [totalIncome] / [totalExpense] 是**全部账单**的汇总,只读
|
||||
class AccountInfo {
|
||||
final int? id;
|
||||
final String name;
|
||||
final double initialBalance;
|
||||
final double balance;
|
||||
final double totalIncome;
|
||||
final double totalExpense;
|
||||
|
||||
const AccountInfo({
|
||||
this.id,
|
||||
this.name = '默认账户',
|
||||
required this.initialBalance,
|
||||
required this.balance,
|
||||
required this.totalIncome,
|
||||
required this.totalExpense,
|
||||
});
|
||||
|
||||
factory AccountInfo.fromJson(Map<String, dynamic> json) {
|
||||
final initial = asDouble(json['initialBalance']);
|
||||
final income = asDouble(json['totalIncome']);
|
||||
final expense = asDouble(json['totalExpense']);
|
||||
return AccountInfo(
|
||||
id: json['id'] == null ? null : asInt(json['id']),
|
||||
name: asNonEmpty(json['name'], '默认账户'),
|
||||
initialBalance: initial,
|
||||
// 后端没给 balance 时本地按同一公式兜底
|
||||
balance: json['balance'] == null
|
||||
? initial + income - expense
|
||||
: asDouble(json['balance']),
|
||||
totalIncome: income,
|
||||
totalExpense: expense,
|
||||
);
|
||||
}
|
||||
|
||||
static const empty = AccountInfo(
|
||||
initialBalance: 0,
|
||||
balance: 0,
|
||||
totalIncome: 0,
|
||||
totalExpense: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 把"用户想要的目标余额"反算成后端可写入的期初余额。
|
||||
///
|
||||
/// 因为 balance = initialBalance + totalIncome - totalExpense,
|
||||
/// 所以要让余额显示为 target,期初余额必须设为:
|
||||
/// `target - totalIncome + totalExpense`
|
||||
double initialBalanceFor({
|
||||
required double targetBalance,
|
||||
required double totalIncome,
|
||||
required double totalExpense,
|
||||
}) {
|
||||
if (!targetBalance.isFinite) return 0;
|
||||
return targetBalance - totalIncome + totalExpense;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
class AuthResponse {
|
||||
final String token;
|
||||
final String? username;
|
||||
final String? nickname;
|
||||
|
||||
const AuthResponse({required this.token, this.username, this.nickname});
|
||||
|
||||
factory AuthResponse.fromJson(Map<String, dynamic> json) => AuthResponse(
|
||||
token: json['token']?.toString() ?? '',
|
||||
username: json['username']?.toString(),
|
||||
nickname: json['nickname']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
class UserInfo {
|
||||
final String? id;
|
||||
final String username;
|
||||
final String nickname;
|
||||
|
||||
const UserInfo({this.id, required this.username, required this.nickname});
|
||||
|
||||
Map<String, dynamic> toJson() => {'id': id, 'username': username, 'nickname': nickname};
|
||||
|
||||
factory UserInfo.fromJson(Map<String, dynamic> json) => UserInfo(
|
||||
id: json['id']?.toString(),
|
||||
username: json['username']?.toString() ?? '',
|
||||
nickname: json['nickname']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import '../utils/date_util.dart';
|
||||
import 'json_util.dart';
|
||||
|
||||
/// 账单(对应后端 BillResponse)
|
||||
///
|
||||
/// 注意:amount 后端恒返回正数,收支方向看 [type]。
|
||||
class Bill {
|
||||
final int id;
|
||||
final int categoryId;
|
||||
final String categoryName;
|
||||
final String categoryIcon;
|
||||
final double amount;
|
||||
final String description;
|
||||
final String billDate; // yyyy-MM-dd
|
||||
final String? imageUrl;
|
||||
final int type; // 1=支出 2=收入
|
||||
final String? createTime;
|
||||
final String? updateTime;
|
||||
|
||||
const Bill({
|
||||
required this.id,
|
||||
required this.categoryId,
|
||||
required this.categoryName,
|
||||
required this.categoryIcon,
|
||||
required this.amount,
|
||||
required this.description,
|
||||
required this.billDate,
|
||||
this.imageUrl,
|
||||
required this.type,
|
||||
this.createTime,
|
||||
this.updateTime,
|
||||
});
|
||||
|
||||
bool get isExpense => type == 1;
|
||||
|
||||
double get absAmount => amount.abs();
|
||||
|
||||
factory Bill.fromJson(Map<String, dynamic> json) => Bill(
|
||||
id: asInt(json['id']),
|
||||
categoryId: asInt(json['categoryId']),
|
||||
categoryName: asNonEmpty(json['categoryName'], '未分类'),
|
||||
categoryIcon: asNonEmpty(json['categoryIcon'], '📦'),
|
||||
amount: asDouble(json['amount']),
|
||||
description: asString(json['description']),
|
||||
billDate: parseBillDate(asString(json['billDate'])),
|
||||
imageUrl: json['imageUrl'] == null ? null : asString(json['imageUrl']),
|
||||
// 后端可能返回 null,缺省按支出处理
|
||||
type: asInt(json['type'], fallback: 1) == 2 ? 2 : 1,
|
||||
createTime: json['createTime'] == null ? null : asString(json['createTime']),
|
||||
updateTime: json['updateTime'] == null ? null : asString(json['updateTime']),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// 账单创建/更新请求体
|
||||
class BillRequest {
|
||||
final int categoryId;
|
||||
final double amount;
|
||||
final String description;
|
||||
final String billDate; // yyyy-MM-dd
|
||||
final int type; // 1=支出,2=收入
|
||||
|
||||
const BillRequest({
|
||||
required this.categoryId,
|
||||
required this.amount,
|
||||
this.description = '',
|
||||
required this.billDate,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'categoryId': categoryId,
|
||||
'amount': amount,
|
||||
'description': description,
|
||||
'billDate': billDate,
|
||||
'type': type,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'json_util.dart';
|
||||
|
||||
/// 预算信息(对应后端 BudgetResponse,仅当月有效)
|
||||
class BudgetInfo {
|
||||
final int? id;
|
||||
final int? year;
|
||||
final int? month;
|
||||
final double amount; // 预算总额
|
||||
final double usedAmount; // 已用
|
||||
final double remainingAmount; // 剩余
|
||||
final double remainingDaily; // 日均可用
|
||||
|
||||
const BudgetInfo({
|
||||
this.id,
|
||||
this.year,
|
||||
this.month,
|
||||
required this.amount,
|
||||
required this.usedAmount,
|
||||
required this.remainingAmount,
|
||||
required this.remainingDaily,
|
||||
});
|
||||
|
||||
bool get hasBudget => amount > 0;
|
||||
|
||||
bool get isOverBudget => amount > 0 && usedAmount > amount;
|
||||
|
||||
/// 已用占比(0~1),未设预算时为 0
|
||||
double get usedRatio {
|
||||
if (amount <= 0 || !usedAmount.isFinite) return 0;
|
||||
return (usedAmount / amount).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
factory BudgetInfo.fromJson(Map<String, dynamic> json) {
|
||||
final amount = asDouble(json['amount']);
|
||||
final used = asDouble(json['usedAmount']);
|
||||
return BudgetInfo(
|
||||
id: json['id'] == null ? null : asInt(json['id']),
|
||||
year: json['year'] == null ? null : asInt(json['year']),
|
||||
month: json['month'] == null ? null : asInt(json['month']),
|
||||
amount: amount,
|
||||
usedAmount: used,
|
||||
remainingAmount:
|
||||
json['remainingAmount'] == null ? amount - used : asDouble(json['remainingAmount']),
|
||||
remainingDaily: asDouble(json['remainingDaily']),
|
||||
);
|
||||
}
|
||||
|
||||
static const empty = BudgetInfo(
|
||||
amount: 0,
|
||||
usedAmount: 0,
|
||||
remainingAmount: 0,
|
||||
remainingDaily: 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// 分类(type: 1=支出,2=收入)
|
||||
class Category {
|
||||
final int id;
|
||||
final String name;
|
||||
final String icon; // emoji
|
||||
final int type;
|
||||
final int? sortOrder;
|
||||
|
||||
const Category({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.type,
|
||||
this.sortOrder,
|
||||
});
|
||||
|
||||
factory Category.fromJson(Map<String, dynamic> json) => Category(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
name: json['name']?.toString() ?? '',
|
||||
icon: json['icon']?.toString() ?? '📦',
|
||||
type: (json['type'] as num?)?.toInt() ?? 1,
|
||||
sortOrder: (json['sortOrder'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/// 后端字段的防御式取值工具
|
||||
///
|
||||
/// 后端没有统一的响应包装,字段类型也可能与预期不符(例如金额返回字符串、
|
||||
/// 数组返回 null)。所有 fromJson 都走这里取值,保证解析绝不抛异常。
|
||||
library;
|
||||
|
||||
int asInt(Object? value, {int fallback = 0}) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
double asDouble(Object? value, {double fallback = 0}) {
|
||||
if (value is double) return value;
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String asString(Object? value, {String fallback = ''}) {
|
||||
if (value == null) return fallback;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/// 取字符串,空串时回退(用于分类名/图标这类必须有值的展示字段)
|
||||
String asNonEmpty(Object? value, String fallback) {
|
||||
final text = asString(value);
|
||||
return text.isEmpty ? fallback : text;
|
||||
}
|
||||
|
||||
/// 取对象数组,非 Map 的元素直接丢弃
|
||||
List<Map<String, dynamic>> asMapList(Object? value) {
|
||||
if (value is! List) return const [];
|
||||
return value.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// OCR 识别出的单条账单
|
||||
class OcrResult {
|
||||
final double amount; // 负数=支出,正数=收入(与 uniApp 语义一致)
|
||||
final String? merchant;
|
||||
final String? date;
|
||||
final double? confidence;
|
||||
|
||||
const OcrResult({
|
||||
required this.amount,
|
||||
this.merchant,
|
||||
this.date,
|
||||
this.confidence,
|
||||
});
|
||||
|
||||
bool get isExpense => amount < 0;
|
||||
|
||||
double get absAmount => amount.abs();
|
||||
|
||||
factory OcrResult.fromJson(Map<String, dynamic> json) => OcrResult(
|
||||
amount: (json['amount'] as num?)?.toDouble() ?? 0,
|
||||
merchant: json['merchant']?.toString(),
|
||||
date: json['date']?.toString(),
|
||||
confidence: (json['confidence'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'json_util.dart';
|
||||
|
||||
/// 统计响应(对应后端 StatisticsResponse)
|
||||
///
|
||||
/// 口径说明:后端的 totalIncome / totalExpense / categoryStatistics 是按
|
||||
/// **分类的 type** 判定收支的,而 /bills 列表是按 **账单的 type** 判定。
|
||||
/// 两者可能不一致——前端不做交叉校验,汇总/饼图一律以此接口为准。
|
||||
class StatisticsResponse {
|
||||
final double totalIncome;
|
||||
final double totalExpense;
|
||||
final double balance;
|
||||
final List<CategoryStat> categoryStatistics;
|
||||
final List<DailyStat> dailyStatistics;
|
||||
|
||||
const StatisticsResponse({
|
||||
required this.totalIncome,
|
||||
required this.totalExpense,
|
||||
required this.balance,
|
||||
required this.categoryStatistics,
|
||||
required this.dailyStatistics,
|
||||
});
|
||||
|
||||
bool get isEmpty => categoryStatistics.isEmpty && totalIncome == 0 && totalExpense == 0;
|
||||
|
||||
factory StatisticsResponse.fromJson(Map<String, dynamic> json) {
|
||||
final income = asDouble(json['totalIncome']);
|
||||
final expense = asDouble(json['totalExpense']);
|
||||
final balanceRaw = json['balance'];
|
||||
return StatisticsResponse(
|
||||
totalIncome: income,
|
||||
totalExpense: expense,
|
||||
// 后端没给 balance 时本地兜底计算
|
||||
balance: balanceRaw == null ? income - expense : asDouble(balanceRaw),
|
||||
categoryStatistics:
|
||||
asMapList(json['categoryStatistics']).map(CategoryStat.fromJson).toList(),
|
||||
dailyStatistics:
|
||||
asMapList(json['dailyStatistics']).map(DailyStat.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static const empty = StatisticsResponse(
|
||||
totalIncome: 0,
|
||||
totalExpense: 0,
|
||||
balance: 0,
|
||||
categoryStatistics: [],
|
||||
dailyStatistics: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// 分类维度的统计项
|
||||
class CategoryStat {
|
||||
final int categoryId;
|
||||
final String categoryName;
|
||||
final String categoryIcon;
|
||||
final double amount;
|
||||
final int type; // 1=支出 2=收入
|
||||
|
||||
const CategoryStat({
|
||||
required this.categoryId,
|
||||
required this.categoryName,
|
||||
required this.categoryIcon,
|
||||
required this.amount,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
bool get isExpense => type != 2;
|
||||
|
||||
factory CategoryStat.fromJson(Map<String, dynamic> json) => CategoryStat(
|
||||
categoryId: asInt(json['categoryId']),
|
||||
categoryName: asNonEmpty(json['categoryName'], '未分类'),
|
||||
categoryIcon: asNonEmpty(json['categoryIcon'], '📦'),
|
||||
amount: asDouble(json['amount']),
|
||||
type: asInt(json['type'], fallback: 1) == 2 ? 2 : 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// 按日趋势项(本期统计页不画折线图,保留以便后续扩展)
|
||||
class DailyStat {
|
||||
final String date; // yyyy-MM-dd
|
||||
final double income;
|
||||
final double expense;
|
||||
|
||||
const DailyStat({required this.date, required this.income, required this.expense});
|
||||
|
||||
factory DailyStat.fromJson(Map<String, dynamic> json) => DailyStat(
|
||||
date: asString(json['date']),
|
||||
income: asDouble(json['income']),
|
||||
expense: asDouble(json['expense']),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user