first commit
This commit is contained in:
@@ -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, '账户操作失败');
|
||||
}
|
||||
}
|
||||
@@ -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, '请求失败');
|
||||
}
|
||||
}
|
||||
@@ -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, '请求失败');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, '分类响应格式异常');
|
||||
}
|
||||
}
|
||||
@@ -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, '识别失败,请重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, '统计加载失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user