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
+54
View File
@@ -0,0 +1,54 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/account.dart';
final accountApiProvider = Provider<AccountApi>((ref) => AccountApi(ref.watch(dioProvider)));
/// 账户接口(后端每个用户只有一个默认账户)
class AccountApi {
final Dio _dio;
AccountApi(this._dio);
/// 账户信息:余额、总收入、总支出(收支为全部账单汇总,只读)
Future<AccountInfo> getAccount() async {
try {
final res = await _dio.get('/accounts');
return AccountInfo.fromJson(_parseObject(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 更新余额。
///
/// 注意:后端这个接口写入的是**期初余额**,传入前需用
/// `initialBalanceFor()` 把目标余额换算成期初余额。
Future<AccountInfo> updateBalance(double initialBalance) async {
try {
final res = await _dio.put('/accounts/balance', data: {
'initialBalance': initialBalance,
});
return AccountInfo.fromJson(_parseObject(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
Map<String, dynamic> _parseObject(Object? data) {
if (data is Map<String, dynamic>) return data;
if (data is Map && data['data'] is Map) {
return Map<String, dynamic>.from(data['data'] as Map);
}
throw ApiException(-1, '账户响应格式异常');
}
Never _unwrap(DioException e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(-1, '账户操作失败');
}
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/auth_response.dart';
final authApiProvider = Provider<AuthApi>((ref) => AuthApi(ref.watch(dioProvider)));
class AuthApi {
final Dio _dio;
AuthApi(this._dio);
/// 登录 → {token, username, nickname}
Future<AuthResponse> login(String username, String password) async {
try {
final res = await _dio.post('/auth/login', data: {
'username': username,
'password': password,
});
return AuthResponse.fromJson(_map(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 注册 → {token, username, nickname}
Future<AuthResponse> register(String username, String password, String nickname) async {
try {
final res = await _dio.post('/auth/register', data: {
'username': username,
'password': password,
'nickname': nickname,
});
return AuthResponse.fromJson(_map(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
Map<String, dynamic> _map(Object? data) {
if (data is Map<String, dynamic>) return data;
throw ApiException(-1, '响应格式异常');
}
Never _unwrap(DioException e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(-1, '请求失败');
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/bill.dart';
import '../models/bill_request.dart';
final billApiProvider = Provider<BillApi>((ref) => BillApi(ref.watch(dioProvider)));
class BillApi {
final Dio _dio;
BillApi(this._dio);
/// 账单列表,可选日期区间(yyyy-MM-dd)。
/// 后端返回裸数组,无分页。
Future<List<Bill>> getBills({String? startDate, String? endDate}) async {
try {
final params = <String, dynamic>{};
if (startDate != null) params['startDate'] = startDate;
if (endDate != null) params['endDate'] = endDate;
final res = await _dio.get('/bills', queryParameters: params);
return _parseList(res.data).map(Bill.fromJson).toList();
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 账单详情
Future<Bill> getBill(int id) async {
try {
final res = await _dio.get('/bills/$id');
return Bill.fromJson(_parseObject(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 创建账单
Future<void> createBill(BillRequest bill) async {
try {
await _dio.post('/bills', data: bill.toJson());
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 批量创建账单(OCR 直接存储)
Future<void> createBillsBatch(List<BillRequest> bills) async {
try {
await _dio.post('/bills/batch', data: {
'bills': bills.map((b) => b.toJson()).toList(),
});
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 更新账单
///
/// 注意:后端对 description 为 null 会覆盖成 null,所以必须回传全部字段
/// (BillRequest.toJson 不含 imageUrl,后端按「null 保留原值」处理,正好保留原图)。
Future<Bill> updateBill(int id, BillRequest bill) async {
try {
final res = await _dio.put('/bills/$id', data: bill.toJson());
return Bill.fromJson(_parseObject(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 删除账单(后端为物理删除,不可恢复)
Future<void> deleteBill(int id) async {
try {
await _dio.delete('/bills/$id');
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 兼容裸数组与 {code,data:[...]} 两种形态
List<Map<String, dynamic>> _parseList(Object? data) {
if (data is List) {
return data.whereType<Map<String, dynamic>>().toList();
}
if (data is Map && data['data'] is List) {
return (data['data'] as List).whereType<Map<String, dynamic>>().toList();
}
throw ApiException(-1, '响应格式异常');
}
Map<String, dynamic> _parseObject(Object? data) {
if (data is Map<String, dynamic>) return data;
if (data is Map && data['data'] is Map) return Map<String, dynamic>.from(data['data'] as Map);
throw ApiException(-1, '响应格式异常');
}
Never _unwrap(DioException e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(-1, '请求失败');
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/budget.dart';
final budgetApiProvider = Provider<BudgetApi>((ref) => BudgetApi(ref.watch(dioProvider)));
/// 预算接口(后端只提供当月预算)
class BudgetApi {
final Dio _dio;
BudgetApi(this._dio);
/// 获取当月预算。
///
/// 未设置预算时后端可能返回 204 / 空 body,此时返回 [BudgetInfo.empty]
/// 而不是抛错——否则首页会因为"没设预算"而进入错误态。
Future<BudgetInfo> getBudget() async {
try {
final res = await _dio.get('/budgets');
return _parse(res.data);
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '预算加载失败');
}
}
/// 设置/修改当月预算(每月生效,可随时修改)。
/// 后端自动填充年月,amount 必须 >= 0。
Future<BudgetInfo> setCurrentBudget(double amount) async {
try {
final res = await _dio.put('/budgets/current', data: {'amount': amount});
return _parse(res.data);
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '预算保存失败');
}
}
BudgetInfo _parse(Object? data) {
if (data == null) return BudgetInfo.empty;
if (data is Map<String, dynamic>) return BudgetInfo.fromJson(data);
if (data is Map && data['data'] is Map) {
return BudgetInfo.fromJson(Map<String, dynamic>.from(data['data'] as Map));
}
return BudgetInfo.empty;
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/category.dart';
final categoryApiProvider = Provider<CategoryApi>((ref) => CategoryApi(ref.watch(dioProvider)));
class CategoryApi {
final Dio _dio;
CategoryApi(this._dio);
/// 获取分类列表,type: 1=支出 2=收入(后端可能返回裸数组或 {code,data})
Future<List<Category>> getCategories(int type) async {
try {
final res = await _dio.get('/categories', queryParameters: {'type': type});
return _parseList(res.data)
.whereType<Map<String, dynamic>>()
.map(Category.fromJson)
.toList();
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '请求失败');
}
}
List<dynamic> _parseList(Object? data) {
if (data is List) return data;
if (data is Map && data['data'] is List) return data['data'] as List;
throw ApiException(-1, '分类响应格式异常');
}
}
+41
View File
@@ -0,0 +1,41 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/ocr_result.dart';
final ocrApiProvider = Provider<OcrApi>((ref) => OcrApi(ref.watch(dioProvider)));
class OcrApi {
final Dio _dio;
OcrApi(this._dio);
/// 上传图片识别账单,字段名固定为 file(与 uniApp upload 对齐)。
/// 后端可能返回裸数组或 {code, data:[...]},两种都兼容。
Future<List<OcrResult>> recognize(String filePath) async {
try {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath, filename: 'receipt.jpg'),
});
final res = await _dio.post('/ocr/recognize', data: formData);
final data = res.data;
List<dynamic> list;
if (data is List) {
list = data;
} else if (data is Map && data['data'] is List) {
list = data['data'] as List;
} else {
throw ApiException(-1, '未识别到账单信息');
}
return list
.whereType<Map<String, dynamic>>()
.map(OcrResult.fromJson)
.toList();
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '识别失败,请重试');
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/statistics_response.dart';
final statisticsApiProvider =
Provider<StatisticsApi>((ref) => StatisticsApi(ref.watch(dioProvider)));
/// 统计接口。
///
/// 注意后端三个接口实际都委托给同一段按日聚合逻辑,区别只在参数:
/// daily 传日期区间,weekly 传周一,monthly 传年月。
class StatisticsApi {
final Dio _dio;
StatisticsApi(this._dio);
/// 按日期区间统计(yyyy-MM-dd)
Future<StatisticsResponse> getDaily({
required String startDate,
required String endDate,
}) async {
return _request('/statistics/daily', {
'startDate': startDate,
'endDate': endDate,
});
}
/// 按周统计,weekStart 为本周一(后端自行算到周内其余日期)
Future<StatisticsResponse> getWeekly({required String weekStart}) async {
return _request('/statistics/weekly', {'weekStart': weekStart});
}
/// 按月统计
Future<StatisticsResponse> getMonthly({required int year, required int month}) async {
return _request('/statistics/monthly', {'year': year, 'month': month});
}
Future<StatisticsResponse> _request(String path, Map<String, dynamic> params) async {
try {
final res = await _dio.get(path, queryParameters: params);
final data = res.data;
if (data is Map<String, dynamic>) return StatisticsResponse.fromJson(data);
if (data is Map && data['data'] is Map) {
return StatisticsResponse.fromJson(Map<String, dynamic>.from(data['data'] as Map));
}
// 空响应(无数据)按空统计处理,不视为错误
return StatisticsResponse.empty;
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '统计加载失败');
}
}
}