first commit

This commit is contained in:
2026-09-21 17:37:53 +08:00
commit 67d4dd5b7c
141 changed files with 8558 additions and 0 deletions
+63
View File
@@ -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;
}
+29
View File
@@ -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() ?? '',
);
}
+52
View File
@@ -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']),
);
}
+24
View File
@@ -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,
};
}
+54
View File
@@ -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,
);
}
+24
View File
@@ -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(),
);
}
+36
View File
@@ -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();
}
+25
View File
@@ -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(),
);
}
+90
View File
@@ -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']),
);
}