Files
jizhi/frontend/lib/shared/api/business_api.dart
T
2026-07-24 23:11:20 +08:00

1520 lines
47 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/sse_frame_accumulator.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/local_database.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:shared_preferences/shared_preferences.dart';
class TxItem {
final int id, ledgerId, categoryId;
final String categoryName, categoryIcon, categoryColor, type, source;
final double amount;
final String? note, paymentMethod, sourceText;
final DateTime occurredAt, updatedAt;
final bool isDeleted;
TxItem.fromJson(Map<String, dynamic> j)
: id = (j['id'] as num).toInt(),
ledgerId = (j['ledgerId'] as num).toInt(),
categoryId = (j['categoryId'] as num).toInt(),
categoryName = j['categoryName'] as String,
categoryIcon = j['categoryIcon'] as String,
categoryColor = j['categoryColor'] as String? ?? 'mint',
type = j['type'] as String,
amount = (j['amount'] as num).toDouble(),
note = j['note'] as String?,
paymentMethod = j['paymentMethod'] as String?,
source = j['source'] as String,
sourceText = j['sourceText'] as String?,
occurredAt = ShanghaiTime.parseCivil(j['occurredAt'] as String),
updatedAt = DateTime.parse(
j['updatedAt'] as String? ?? j['occurredAt'] as String,
).toUtc(),
isDeleted = j['isDeleted'] as bool? ?? false;
bool get isAi => const {
'ai_chat',
'voice',
'ocr',
'screenshot',
'recognition_ai',
}.contains(source);
bool get isRecognition =>
source == 'accessibility' ||
source == 'notification' ||
source == 'local_ocr';
bool get isIncome => type == 'income';
String get sourceLabel => switch (source) {
'ai_chat' => 'AI 聊天',
'voice' => 'AI 语音解析',
'ocr' => 'AI 图片识别',
'screenshot' => 'AI 截屏识别',
'recognition_ai' => '智能识别 · AI 补全',
'accessibility' => '智能识别 · 页面事件',
'notification' => '智能识别 · 通知',
'local_ocr' => '智能识别 · 本地视觉',
_ => '手动记账',
};
}
class DayGroup {
final DateTime date;
final double expense, income;
final List<TxItem> items;
DayGroup.fromJson(Map<String, dynamic> j)
: date = DateTime.parse(j['date'] as String),
expense = (j['expense'] as num).toDouble(),
income = (j['income'] as num).toDouble(),
items = (j['items'] as List)
.map((e) => TxItem.fromJson(e as Map<String, dynamic>))
.toList();
}
class MonthSummary {
final int year, month, count;
final double income, expense, balance;
final List<DayGroup> days;
MonthSummary.fromJson(Map<String, dynamic> j)
: year = j['year'] as int,
month = j['month'] as int,
count = j['count'] as int,
income = (j['income'] as num).toDouble(),
expense = (j['expense'] as num).toDouble(),
balance = (j['balance'] as num).toDouble(),
days = (j['days'] as List)
.map((e) => DayGroup.fromJson(e as Map<String, dynamic>))
.toList();
}
class CategoryItem {
final int id, sortOrder;
final String name, iconKey, colorKey, type;
final bool isCustom;
CategoryItem.fromJson(Map<String, dynamic> j)
: id = (j['id'] as num).toInt(),
name = j['name'] as String,
iconKey = j['iconKey'] as String,
colorKey = j['colorKey'] as String? ?? 'mint',
type = j['type'] as String,
sortOrder = j['sortOrder'] as int,
isCustom = j['isCustom'] as bool;
}
class CategoryStat {
final int categoryId;
final String name, iconKey, colorKey;
final double amount, percent;
CategoryStat.fromJson(Map<String, dynamic> j)
: categoryId = (j['categoryId'] as num).toInt(),
name = j['name'] as String,
iconKey = j['iconKey'] as String,
colorKey = j['colorKey'] as String? ?? 'mint',
amount = (j['amount'] as num).toDouble(),
percent = (j['percent'] as num).toDouble();
}
class MonthStats {
final int year, month;
final double totalExpense, totalIncome;
final List<CategoryStat> byCategory;
final List<double> dailyExpenses;
final String? aiAnalysis;
MonthStats.fromJson(Map<String, dynamic> j)
: year = j['year'] as int,
month = j['month'] as int,
totalExpense = (j['totalExpense'] as num).toDouble(),
totalIncome = (j['totalIncome'] as num).toDouble(),
byCategory = (j['byCategory'] as List)
.map((e) => CategoryStat.fromJson(e as Map<String, dynamic>))
.toList(),
dailyExpenses = (j['dailyExpenses'] as List)
.map((e) => (e as num).toDouble())
.toList(),
aiAnalysis = j['aiAnalysis'] as String?;
}
class PeriodTrendPoint {
final String label;
final DateTime date;
final double expense, income;
PeriodTrendPoint.fromJson(Map<String, dynamic> json)
: label = json['label'] as String,
date = DateTime.parse(json['date'] as String),
expense = (json['expense'] as num).toDouble(),
income = (json['income'] as num).toDouble();
}
class PeriodStats {
final String periodType, periodLabel;
final DateTime startDate, endDate;
final double totalExpense, totalIncome, balance;
final int count;
final List<CategoryStat> byCategory;
final List<PeriodTrendPoint> trend;
final String? analysis;
PeriodStats.fromJson(Map<String, dynamic> json)
: periodType = json['periodType'] as String,
periodLabel = json['periodLabel'] as String,
startDate = DateTime.parse(json['startDate'] as String),
endDate = DateTime.parse(json['endDate'] as String),
totalExpense = (json['totalExpense'] as num).toDouble(),
totalIncome = (json['totalIncome'] as num).toDouble(),
balance = (json['balance'] as num).toDouble(),
count = (json['count'] as num).toInt(),
byCategory = (json['byCategory'] as List)
.map((item) => CategoryStat.fromJson(item as Map<String, dynamic>))
.toList(),
trend = (json['trend'] as List)
.map(
(item) => PeriodTrendPoint.fromJson(item as Map<String, dynamic>),
)
.toList(),
analysis = json['analysis'] as String?;
}
class TxApi {
static final _dio = ApiClient.instance.dio;
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
static bool get _queueOfflineChanges =>
SessionStore.instance.isAccount && SessionStore.instance.cloudSyncEnabled;
static Future<MonthSummary> month(int year, int month) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
return MonthSummary.fromJson(
LocalDatabase.instance.monthSummary(year, month, _ledgerId),
);
}
try {
final response = await _dio.get(
'/api/transactions/month',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheMonthSummary(json);
return MonthSummary.fromJson(json);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return MonthSummary.fromJson(
LocalDatabase.instance.monthSummary(year, month, _ledgerId),
);
}
}
static Future<MonthStats> stats(int year, int month) async {
final session = SessionStore.instance;
if (!session.shouldUseLocalOnly) {
try {
final response = await _dio.get(
'/api/transactions/stats',
queryParameters: {
'year': year,
'month': month,
'ledgerId': _ledgerId,
},
);
return MonthStats.fromJson(response.data as Map<String, dynamic>);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
}
}
final local = LocalDatabase.instance.periodStats(
'month',
DateTime(year, month, 1),
_ledgerId,
);
return MonthStats.fromJson({
'year': year,
'month': month,
'totalExpense': local['totalExpense'],
'totalIncome': local['totalIncome'],
'byCategory': local['byCategory'],
'dailyExpenses': (local['trend'] as List)
.map((item) => (item as Map<String, dynamic>)['expense'])
.toList(),
'aiAnalysis': local['analysis'],
});
}
static Future<PeriodStats> periodStats(String period, DateTime anchor) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
return PeriodStats.fromJson(
LocalDatabase.instance.periodStats(period, anchor, _ledgerId),
);
}
try {
final response = await _dio.get(
'/api/transactions/stats/period',
queryParameters: {
'period': period,
'anchor':
'${anchor.year.toString().padLeft(4, '0')}-'
'${anchor.month.toString().padLeft(2, '0')}-'
'${anchor.day.toString().padLeft(2, '0')}',
'ledgerId': _ledgerId,
},
);
return PeriodStats.fromJson(response.data as Map<String, dynamic>);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return PeriodStats.fromJson(
LocalDatabase.instance.periodStats(period, anchor, _ledgerId),
);
}
}
static Future<List<CategoryItem>> categories(String type) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
return LocalDatabase.instance
.categories(type)
.map(CategoryItem.fromJson)
.toList();
}
try {
final response = await _dio.get(
'/api/categories',
queryParameters: {'type': type},
);
final values = response.data as List;
LocalDatabase.instance.cacheCategories(values, replaceType: type);
return values
.map((item) => CategoryItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return LocalDatabase.instance
.categories(type)
.map(CategoryItem.fromJson)
.toList();
}
}
static Future<TxItem> create({
required int categoryId,
required String type,
required double amount,
String? note,
String? paymentMethod,
String? source,
String? sourceText,
DateTime? occurredAt,
String? clientRequestId,
}) async {
final payload = <String, dynamic>{
'ledgerId': _ledgerId,
'categoryId': categoryId,
'type': type,
'amount': amount,
'note': note,
'paymentMethod': paymentMethod,
'source': source,
'sourceText': sourceText,
'occurredAt': ShanghaiTime.civilToUtc(
occurredAt ?? ShanghaiTime.now,
).toIso8601String(),
if (clientRequestId != null) 'clientRequestId': clientRequestId,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || categoryId < 0 || _ledgerId < 0) {
final local = LocalDatabase.instance.createTransaction(payload);
if (_queueOfflineChanges) {
LocalDatabase.instance.enqueueSync(
'transaction',
local['id'] as int,
'create',
payload,
);
}
return TxItem.fromJson(local);
}
try {
final response = await _dio.post('/api/transactions', data: payload);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheTransaction(json);
return TxItem.fromJson(json);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
final local = LocalDatabase.instance.createTransaction(payload);
LocalDatabase.instance.enqueueSync(
'transaction',
local['id'] as int,
'create',
payload,
);
return TxItem.fromJson(local);
}
}
static Future<void> delete(int id) async {
final baseUpdatedAt = LocalDatabase.instance.transaction(
id,
includeDeleted: true,
)?['updatedAt'];
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
LocalDatabase.instance.softDeleteTransaction(id);
if (_queueOfflineChanges) {
LocalDatabase.instance.enqueueSync('transaction', id, 'delete', {
'id': id,
'baseUpdatedAt': baseUpdatedAt,
});
}
return;
}
try {
await _dio.delete('/api/transactions/$id');
LocalDatabase.instance.softDeleteTransaction(id);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
LocalDatabase.instance.softDeleteTransaction(id);
LocalDatabase.instance.enqueueSync('transaction', id, 'delete', {
'id': id,
});
}
}
static Future<TxItem> update(
int id, {
required int ledgerId,
required int categoryId,
required String type,
required double amount,
required DateTime occurredAt,
String? note,
String? paymentMethod,
}) async {
final baseUpdatedAt = LocalDatabase.instance.transaction(
id,
includeDeleted: true,
)?['updatedAt'];
final payload = <String, dynamic>{
'ledgerId': ledgerId,
'categoryId': categoryId,
'type': type,
'amount': amount,
'note': note,
'paymentMethod': paymentMethod,
'occurredAt': ShanghaiTime.civilToUtc(occurredAt).toIso8601String(),
if (baseUpdatedAt != null) 'baseUpdatedAt': baseUpdatedAt,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly ||
id < 0 ||
categoryId < 0 ||
ledgerId < 0) {
final local = LocalDatabase.instance.updateTransaction(id, payload);
if (_queueOfflineChanges) {
LocalDatabase.instance.enqueueSync(
'transaction',
id,
'update',
payload,
);
}
return TxItem.fromJson(local);
}
try {
final response = await _dio.put('/api/transactions/$id', data: payload);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheTransaction(json);
return TxItem.fromJson(json);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
final local = LocalDatabase.instance.updateTransaction(id, payload);
LocalDatabase.instance.enqueueSync('transaction', id, 'update', payload);
return TxItem.fromJson(local);
}
}
static Future<List<TxItem>> recycleBin() async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
return LocalDatabase.instance
.recycleBin(_ledgerId)
.map(TxItem.fromJson)
.toList();
}
try {
final response = await _dio.get(
'/api/transactions/recycle-bin',
queryParameters: {'ledgerId': _ledgerId},
);
final values = response.data as List;
LocalDatabase.instance.cacheTransactions(values);
return values
.map((item) => TxItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return LocalDatabase.instance
.recycleBin(_ledgerId)
.map(TxItem.fromJson)
.toList();
}
}
static Future<TxItem> restore(int id) async {
final baseUpdatedAt = LocalDatabase.instance.transaction(
id,
includeDeleted: true,
)?['updatedAt'];
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
final local = LocalDatabase.instance.restoreTransaction(id);
if (_queueOfflineChanges) {
LocalDatabase.instance.enqueueSync('transaction', id, 'restore', {
'id': id,
'baseUpdatedAt': baseUpdatedAt,
});
}
return TxItem.fromJson(local);
}
try {
final response = await _dio.post('/api/transactions/$id/restore');
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheTransaction(json);
return TxItem.fromJson(json);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
final local = LocalDatabase.instance.restoreTransaction(id);
LocalDatabase.instance.enqueueSync('transaction', id, 'restore', {
'id': id,
});
return TxItem.fromJson(local);
}
}
static Future<void> permanentlyDelete(int id) async {
if (SessionStore.instance.shouldUseLocalOnly || id < 0) {
LocalDatabase.instance.permanentlyDeleteTransaction(id);
return;
}
await _dio.delete('/api/transactions/$id/permanent');
LocalDatabase.instance.permanentlyDeleteTransaction(id);
}
static Future<void> clearRecycleBin() async {
if (SessionStore.instance.shouldUseLocalOnly) {
LocalDatabase.instance.clearRecycleBin(_ledgerId);
return;
}
await _dio.delete(
'/api/transactions/recycle-bin',
queryParameters: {'ledgerId': _ledgerId},
);
LocalDatabase.instance.clearRecycleBin(_ledgerId);
}
}
class ChatMsg {
final int id;
final String role, type, content;
final TxItem? transaction;
final DateTime createdAt;
ChatMsg.fromJson(Map<String, dynamic> j)
: id = (j['id'] as num).toInt(),
role = j['role'] as String,
type = j['type'] as String,
content = j['content'] as String,
transaction = j['transaction'] == null
? null
: TxItem.fromJson(j['transaction'] as Map<String, dynamic>),
createdAt = ShanghaiTime.parseCivil(j['createdAt'] as String);
}
class ChatApi {
static final _dio = ApiClient.instance.dio;
static Future<List<ChatMsg>> history({int limit = 50}) async {
SessionStore.instance.requireOnline('AI 聊天需要登录并连接网络');
final res = await _dio.get(
'/api/chat/messages',
queryParameters: {'limit': limit},
);
return (res.data as List)
.map((e) => ChatMsg.fromJson(e as Map<String, dynamic>))
.toList();
}
static Future<void> clearContext() async {
SessionStore.instance.requireOnline('AI 聊天需要登录并连接网络');
await _dio.post('/api/chat/context/clear');
}
static Future<List<ChatMsg>> send(
String content, {
String type = 'text',
}) async {
SessionStore.instance.requireOnline('AI 聊天需要登录并连接网络');
final res = await _dio.post(
'/api/chat/messages',
data: {
'content': content,
'type': type,
if (CurrentLedgerStore.instance.currentId case final id?)
'ledgerId': id,
},
);
return (res.data['messages'] as List)
.map((e) => ChatMsg.fromJson(e as Map<String, dynamic>))
.toList();
}
static Future<void> sendStream(
String content, {
required void Function(String full) onToken,
void Function(List<ChatMsg> msgs)? onDone,
void Function(String err)? onError,
}) async {
SessionStore.instance.requireOnline('AI 聊天需要登录并连接网络');
try {
final resp = await _dio.post(
'/api/chat/messages/stream',
data: {
'content': content,
if (CurrentLedgerStore.instance.currentId case final id?)
'ledgerId': id,
},
options: Options(
responseType: ResponseType.stream,
receiveTimeout: const Duration(minutes: 3),
headers: {'Accept': 'text/event-stream'},
),
);
final stream = (resp.data as ResponseBody).stream.map<List<int>>(
(bytes) => bytes,
);
final completer = Completer<void>();
final frames = SseFrameAccumulator();
var fullText = '';
int? messageId;
String? streamError;
List<ChatMsg>? completedMessages;
void handleEvent(String event) {
final parsed = _parseSseEvent(event, fullText);
fullText = parsed.$1;
if (parsed.$2) onToken(fullText);
if (parsed.$3 != null) messageId = parsed.$3;
if (parsed.$5 != null) completedMessages = parsed.$5;
if (parsed.$4 != null) {
streamError = parsed.$4;
onError?.call(parsed.$4!);
}
}
stream
.transform(const Utf8Decoder(allowMalformed: true))
.listen(
(chunk) {
for (final event in frames.add(chunk)) {
handleEvent(event);
}
},
onDone: () {
for (final event in frames.close()) {
handleEvent(event);
}
if (!completer.isCompleted) completer.complete();
},
onError: (e) {
onError?.call(e.toString());
if (!completer.isCompleted) completer.completeError(e);
},
);
await completer.future;
if (completedMessages != null) {
onDone?.call(completedMessages!);
return;
}
if (fullText.isEmpty) {
if (streamError == null) onError?.call('未收到 AI 回复,请稍后重试');
return;
}
final message = ChatMsg.fromJson({
'id': messageId ?? -1,
'role': 'assistant',
'type': 'text',
'content': fullText,
'transaction': null,
'createdAt': DateTime.now().toUtc().toIso8601String(),
});
onDone?.call([message]);
} catch (e) {
onError?.call(e.toString());
}
}
static (String, bool, int?, String?, List<ChatMsg>?) _parseSseEvent(
String event,
String currentText,
) {
final payload = StringBuffer();
for (final rawLine in event.split('\n')) {
final line = rawLine.trimRight();
if (line.startsWith('data:')) {
payload.write(line.substring(5).trimLeft());
}
}
if (payload.isEmpty) return (currentText, false, null, null, null);
try {
final data = jsonDecode(payload.toString());
if (data is Map) {
if (data['t'] != null) {
return (currentText + data['t'].toString(), true, null, null, null);
}
if (data['done'] != null) {
var full = currentText;
int? id;
List<ChatMsg>? messages;
final rawMessages = data['messages'];
if (rawMessages is List) {
messages = rawMessages
.map(
(item) =>
ChatMsg.fromJson(Map<String, dynamic>.from(item as Map)),
)
.toList();
for (final message in messages) {
if (message.type == 'text' && message.role == 'assistant') {
full = message.content;
id = message.id;
}
}
} else {
final done = data['done'];
if (done is bool && done) {
if (data['c'] != null) full = data['c'].toString();
id = (data['id'] as num?)?.toInt();
} else {
final rawDone = done.toString();
final splitIndex = rawDone.lastIndexOf('|');
if (splitIndex > -1) {
full = rawDone.substring(0, splitIndex);
id = int.tryParse(rawDone.substring(splitIndex + 1));
if (id == null) full = rawDone;
} else {
full = rawDone;
}
}
}
return (full, false, id, null, messages);
}
if (data['error'] != null) {
return (currentText, false, null, data['error'].toString(), null);
}
}
} catch (_) {}
return (currentText, false, null, null, null);
}
}
class BudgetItem {
final int? categoryId;
final String? categoryName, categoryIcon, categoryColor;
final double amount, spent;
final bool isRecurring;
BudgetItem.fromJson(Map<String, dynamic> j)
: categoryId = (j['categoryId'] as num?)?.toInt(),
categoryName = j['categoryName'] as String?,
categoryIcon = j['categoryIcon'] as String?,
categoryColor = j['categoryColor'] as String? ?? 'mint',
amount = (j['amount'] as num).toDouble(),
spent = (j['spent'] as num).toDouble(),
isRecurring = j['isRecurring'] as bool? ?? false;
double get ratio => amount == 0 ? 0 : (spent / amount).clamp(0, 1);
}
class BudgetsData {
final BudgetItem? total;
final List<BudgetItem> categories;
BudgetsData.fromJson(Map<String, dynamic> j)
: total = j['total'] == null
? null
: BudgetItem.fromJson(j['total'] as Map<String, dynamic>),
categories = (j['categories'] as List)
.map((e) => BudgetItem.fromJson(e as Map<String, dynamic>))
.toList();
}
class BudgetRecommendationItem {
final int categoryId;
final String categoryName, categoryIcon, colorKey, confidence;
final double suggestedAmount, currentSpent, historicalAverage;
BudgetRecommendationItem.fromJson(Map<String, dynamic> j)
: categoryId = (j['categoryId'] as num).toInt(),
categoryName = j['categoryName'] as String,
categoryIcon = j['categoryIcon'] as String,
colorKey = j['colorKey'] as String? ?? 'mint',
suggestedAmount = (j['suggestedAmount'] as num).toDouble(),
currentSpent = (j['currentSpent'] as num).toDouble(),
historicalAverage = (j['historicalAverage'] as num).toDouble(),
confidence = j['confidence'] as String;
}
class BudgetRecommendations {
final int year, month;
final double suggestedTotal;
final String message, adjustmentSummary;
final List<String> warnings;
final List<BudgetRecommendationItem> items;
BudgetRecommendations.fromJson(Map<String, dynamic> j)
: year = (j['year'] as num).toInt(),
month = (j['month'] as num).toInt(),
suggestedTotal = (j['suggestedTotal'] as num).toDouble(),
message = j['message'] as String,
adjustmentSummary = j['adjustmentSummary'] as String? ?? '',
warnings = (j['warnings'] as List? ?? const [])
.map((item) => item.toString())
.toList(),
items = (j['items'] as List)
.map(
(e) => BudgetRecommendationItem.fromJson(e as Map<String, dynamic>),
)
.toList();
}
class BudgetRefinementTurn {
final String role, content;
const BudgetRefinementTurn({required this.role, required this.content});
Map<String, dynamic> toJson() => {'role': role, 'content': content};
}
class BudgetApi {
static final _dio = ApiClient.instance.dio;
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
static Future<BudgetsData> get(int year, int month) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
return BudgetsData.fromJson(
LocalDatabase.instance.budgets(year, month, _ledgerId),
);
}
try {
final response = await _dio.get(
'/api/budgets',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
);
return BudgetsData.fromJson(response.data as Map<String, dynamic>);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return BudgetsData.fromJson(
LocalDatabase.instance.budgets(year, month, _ledgerId),
);
}
}
static Future<void> upsert(
int year,
int month, {
int? categoryId,
required double amount,
bool recurring = false,
}) async {
final payload = {
'categoryId': categoryId,
'amount': amount,
'recurring': recurring,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || (categoryId ?? 0) < 0 || _ledgerId < 0) {
LocalDatabase.instance.upsertBudget(
year,
month,
_ledgerId,
categoryId,
amount,
recurring,
);
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync(
'budget',
categoryId ?? 0,
'upsert',
{...payload, 'year': year, 'month': month, 'ledgerId': _ledgerId},
);
}
return;
}
try {
await _dio.put(
'/api/budgets',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
data: payload,
);
LocalDatabase.instance.upsertBudget(
year,
month,
_ledgerId,
categoryId,
amount,
recurring,
);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
LocalDatabase.instance.upsertBudget(
year,
month,
_ledgerId,
categoryId,
amount,
recurring,
);
LocalDatabase.instance.enqueueSync('budget', categoryId ?? 0, 'upsert', {
...payload,
'year': year,
'month': month,
'ledgerId': _ledgerId,
});
}
}
static Future<BudgetRecommendations> recommendations(
int year,
int month,
) async {
SessionStore.instance.requireOnline('AI 预算建议需要登录并连接网络');
final response = await _dio.get(
'/api/budgets/recommendations',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
);
return BudgetRecommendations.fromJson(
response.data as Map<String, dynamic>,
);
}
static Future<BudgetRecommendations> refineRecommendation(
int year,
int month, {
required String instruction,
required double suggestedTotal,
required Map<int, double> amounts,
required List<BudgetRefinementTurn> history,
}) async {
SessionStore.instance.requireOnline('调整 AI 预算建议需要登录并连接网络');
final response = await _dio.post(
'/api/budgets/recommendations/refine',
data: {
'ledgerId': _ledgerId,
'year': year,
'month': month,
'instruction': instruction,
'currentDraft': {
'suggestedTotal': suggestedTotal,
'items': amounts.entries
.map((entry) => {'categoryId': entry.key, 'amount': entry.value})
.toList(),
},
'history': history.take(12).map((turn) => turn.toJson()).toList(),
},
);
return BudgetRecommendations.fromJson(
response.data as Map<String, dynamic>,
);
}
static Future<void> applyBatch(
int year,
int month, {
required bool recurring,
required Map<int?, double> amounts,
}) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || _ledgerId < 0) {
LocalDatabase.instance.applyBudgets(
year,
month,
_ledgerId,
recurring,
amounts,
);
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('budget', 0, 'batch', {
'year': year,
'month': month,
'ledgerId': _ledgerId,
'recurring': recurring,
'items': amounts.entries
.map((entry) => {'categoryId': entry.key, 'amount': entry.value})
.toList(),
});
}
return;
}
await _dio.put(
'/api/budgets/batch',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
data: {
'recurring': recurring,
'items': amounts.entries
.map((entry) => {'categoryId': entry.key, 'amount': entry.value})
.toList(),
},
);
LocalDatabase.instance.applyBudgets(
year,
month,
_ledgerId,
recurring,
amounts,
);
}
}
class ReportCategoryRank {
final String name;
final String iconKey;
final String colorKey;
final double amount;
final double percent;
const ReportCategoryRank({
required this.name,
required this.iconKey,
required this.colorKey,
required this.amount,
required this.percent,
});
factory ReportCategoryRank.fromJson(Map<String, dynamic> json) =>
ReportCategoryRank(
name: json['name'] as String,
iconKey: json['iconKey'] as String? ?? 'tag',
colorKey: json['colorKey'] as String? ?? 'mint',
amount: (json['amount'] as num).toDouble(),
percent: (json['percent'] as num).toDouble(),
);
static List<ReportCategoryRank> fromReport(Map<String, dynamic> json) {
final values = (json['categoryRanking'] as List<dynamic>? ?? const [])
.map(
(item) => ReportCategoryRank.fromJson(item as Map<String, dynamic>),
)
.toList();
if (values.isNotEmpty || json['topCategory'] == null) return values;
return [
ReportCategoryRank(
name: json['topCategory'] as String,
iconKey: 'tag',
colorKey: 'mint',
amount: (json['topCategoryAmount'] as num).toDouble(),
percent: (json['topCategoryPercent'] as num).toDouble(),
),
];
}
}
class MonthlyReport {
final int year, month, count;
final double income,
expense,
balance,
aiRatio,
topDayAmount,
topCategoryAmount,
topCategoryPercent;
final String? topDay, topDayNote, topCategory;
final List<ReportCategoryRank> categoryRanking;
final String roast;
MonthlyReport.fromJson(Map<String, dynamic> j)
: year = j['year'] as int,
month = j['month'] as int,
count = j['count'] as int,
income = (j['income'] as num).toDouble(),
expense = (j['expense'] as num).toDouble(),
balance = (j['balance'] as num).toDouble(),
aiRatio = (j['aiRatio'] as num).toDouble(),
topDay = j['topDay'] as String?,
topDayAmount = (j['topDayAmount'] as num).toDouble(),
topDayNote = j['topDayNote'] as String?,
topCategory = j['topCategory'] as String?,
topCategoryAmount = (j['topCategoryAmount'] as num).toDouble(),
topCategoryPercent = (j['topCategoryPercent'] as num).toDouble(),
categoryRanking = ReportCategoryRank.fromReport(j),
roast = j['roast'] as String;
}
class PeriodReport {
final String periodType, periodLabel;
final DateTime startDate, endDate;
final int count;
final double income,
expense,
balance,
aiRatio,
peakAmount,
topCategoryAmount,
topCategoryPercent;
final String? peakLabel, peakNote, topCategory;
final List<ReportCategoryRank> categoryRanking;
final String commentary;
PeriodReport.fromJson(Map<String, dynamic> j)
: periodType = j['periodType'] as String,
periodLabel = j['periodLabel'] as String,
startDate = DateTime.parse(j['startDate'] as String),
endDate = DateTime.parse(j['endDate'] as String),
count = (j['count'] as num).toInt(),
income = (j['income'] as num).toDouble(),
expense = (j['expense'] as num).toDouble(),
balance = (j['balance'] as num).toDouble(),
aiRatio = (j['aiRatio'] as num).toDouble(),
peakLabel = j['peakLabel'] as String?,
peakAmount = (j['peakAmount'] as num).toDouble(),
peakNote = j['peakNote'] as String?,
topCategory = j['topCategory'] as String?,
topCategoryAmount = (j['topCategoryAmount'] as num).toDouble(),
topCategoryPercent = (j['topCategoryPercent'] as num).toDouble(),
categoryRanking = ReportCategoryRank.fromReport(j),
commentary = j['commentary'] as String;
PeriodReport.fromMonthly(MonthlyReport report)
: periodType = 'monthly',
periodLabel = '${report.year}${report.month}月',
startDate = DateTime(report.year, report.month),
endDate = DateTime(report.year, report.month + 1),
count = report.count,
income = report.income,
expense = report.expense,
balance = report.balance,
aiRatio = report.aiRatio,
peakLabel = report.topDay,
peakAmount = report.topDayAmount,
peakNote = report.topDayNote,
topCategory = report.topCategory,
topCategoryAmount = report.topCategoryAmount,
topCategoryPercent = report.topCategoryPercent,
categoryRanking = report.categoryRanking,
commentary = report.roast;
}
class ReportApi {
static final _dio = ApiClient.instance.dio;
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
static Future<PeriodReport> weekly(DateTime date) async {
if (SessionStore.instance.shouldUseLocalOnly) {
return _localPeriod('week', date);
}
try {
final value =
'${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final response = await _dio.get(
'/api/reports/weekly',
queryParameters: {'date': value, 'ledgerId': _ledgerId},
);
return PeriodReport.fromJson(response.data as Map<String, dynamic>);
} catch (error) {
if (!isConnectivityError(error) || !SessionStore.instance.isAccount)
rethrow;
return _localPeriod('week', date);
}
}
static Future<PeriodReport> monthlyPeriod(int year, int month) async {
if (SessionStore.instance.shouldUseLocalOnly) {
return _localPeriod('month', DateTime(year, month, 1));
}
try {
return PeriodReport.fromMonthly(await monthly(year, month));
} catch (error) {
if (!isConnectivityError(error) || !SessionStore.instance.isAccount)
rethrow;
return _localPeriod('month', DateTime(year, month, 1));
}
}
static Future<PeriodReport> yearly(int year) async {
if (SessionStore.instance.shouldUseLocalOnly) {
return _localPeriod('year', DateTime(year, 1, 1));
}
try {
final response = await _dio.get(
'/api/reports/yearly',
queryParameters: {'year': year, 'ledgerId': _ledgerId},
);
return PeriodReport.fromJson(response.data as Map<String, dynamic>);
} catch (error) {
if (!isConnectivityError(error) || !SessionStore.instance.isAccount)
rethrow;
return _localPeriod('year', DateTime(year, 1, 1));
}
}
static Future<MonthlyReport> monthly(int year, int month) async {
final response = await _dio.get(
'/api/reports/monthly',
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
);
return MonthlyReport.fromJson(response.data as Map<String, dynamic>);
}
static PeriodReport _localPeriod(String period, DateTime anchor) =>
PeriodReport.fromJson(
LocalDatabase.instance.periodReport(period, anchor, _ledgerId),
);
}
class SearchApi {
static final _dio = ApiClient.instance.dio;
static Future<List<TxItem>> search({
String? q,
bool aiOnly = false,
int? categoryId,
String? type,
double? minAmount,
double? maxAmount,
DateTime? from,
DateTime? to,
DateTime? beforeOccurredAt,
int? beforeId,
int limit = 50,
}) async {
final ledgerId = CurrentLedgerStore.instance.currentId ?? 1;
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
return LocalDatabase.instance
.searchTransactions(
ledgerId: ledgerId,
query: q,
aiOnly: aiOnly,
categoryId: categoryId,
type: type,
minAmount: minAmount,
maxAmount: maxAmount,
from: from,
to: to,
beforeOccurredAt: beforeOccurredAt,
beforeId: beforeId,
limit: limit,
)
.map(TxItem.fromJson)
.toList();
}
try {
final response = await _dio.get(
'/api/search',
queryParameters: {
if (q != null && q.isNotEmpty) 'q': q,
'ledgerId': ledgerId,
if (categoryId != null) 'categoryId': categoryId,
if (type != null) 'type': type,
if (aiOnly) 'aiOnly': true,
if (minAmount != null) 'minAmount': minAmount,
if (maxAmount != null) 'maxAmount': maxAmount,
if (from != null)
'from': ShanghaiTime.civilToUtc(from).toIso8601String(),
if (to != null) 'to': ShanghaiTime.civilToUtc(to).toIso8601String(),
if (beforeOccurredAt != null)
'beforeOccurredAt': ShanghaiTime.civilToUtc(
beforeOccurredAt,
).toIso8601String(),
if (beforeId != null) 'beforeId': beforeId,
'limit': limit,
},
);
final values = response.data as List;
LocalDatabase.instance.cacheTransactions(values);
return values
.map((item) => TxItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
return LocalDatabase.instance
.searchTransactions(
ledgerId: ledgerId,
query: q,
aiOnly: aiOnly,
categoryId: categoryId,
type: type,
minAmount: minAmount,
maxAmount: maxAmount,
from: from,
to: to,
beforeOccurredAt: beforeOccurredAt,
beforeId: beforeId,
limit: limit,
)
.map(TxItem.fromJson)
.toList();
}
}
}
class StickerItem {
final String key, label, groupKey;
StickerItem.fromJson(Map<String, dynamic> j)
: key = j['key'] as String,
label = j['label'] as String,
groupKey = j['groupKey'] as String;
}
class StickerApi {
static final _dio = ApiClient.instance.dio;
static const _cacheKey = 'global_stickers_v1';
static const _fallback = <Map<String, String>>[
{'key': 'salary', 'label': '发工资啦', 'groupKey': 'default'},
{'key': 'forgive', 'label': '求原谅', 'groupKey': 'default'},
{'key': 'fire', 'label': '剁手警告', 'groupKey': 'default'},
{'key': 'empty_wallet', 'label': '钱包空空', 'groupKey': 'default'},
{'key': 'angry', 'label': '生气', 'groupKey': 'default'},
{'key': 'happy', 'label': '开心', 'groupKey': 'default'},
{'key': 'treat', 'label': '犒劳自己', 'groupKey': 'default'},
{'key': 'shopping_joy', 'label': '购物狂喜', 'groupKey': 'default'},
];
static Future<List<StickerItem>> list() async {
final preferences = await SharedPreferences.getInstance();
try {
final res = await _dio.get('/api/stickers');
final values = (res.data as List)
.map((e) => StickerItem.fromJson(e as Map<String, dynamic>))
.toList();
if (values.isNotEmpty) {
await preferences.setString(
_cacheKey,
jsonEncode(
values
.map(
(item) => {
'key': item.key,
'label': item.label,
'groupKey': item.groupKey,
},
)
.toList(),
),
);
return values;
}
} catch (_) {}
final cached = preferences.getString(_cacheKey);
if (cached != null) {
try {
final values = (jsonDecode(cached) as List)
.map(
(item) =>
StickerItem.fromJson(Map<String, dynamic>.from(item as Map)),
)
.toList();
if (values.isNotEmpty) return values;
} catch (_) {
await preferences.remove(_cacheKey);
}
}
return _fallback.map(StickerItem.fromJson).toList();
}
}
class ParsedDraft {
final bool matched;
final int categoryId;
final String categoryName, categoryIcon, note, type;
final double amount;
final String? paymentMethod;
ParsedDraft.fromJson(Map<String, dynamic> j)
: matched = j['matched'] as bool,
categoryId = (j['categoryId'] as num).toInt(),
categoryName = j['categoryName'] as String,
categoryIcon = j['categoryIcon'] as String,
amount = (j['amount'] as num).toDouble(),
paymentMethod = j['paymentMethod'] as String?,
note = j['note'] as String,
type = switch (j['type']?.toString().toLowerCase()) {
'income' => 'income',
'expense' => 'expense',
_ => 'unknown',
};
}
class ParseApi {
static final _dio = ApiClient.instance.dio;
static Future<ParsedDraft> parse(String text, {String? source}) async {
SessionStore.instance.requireOnline('AI 文本和语音解析需要登录并连接网络');
final res = await _dio.post(
'/api/parse',
data: {'text': text, if (source != null) 'source': source},
);
return ParsedDraft.fromJson(res.data as Map<String, dynamic>);
}
static Future<TxItem> confirm(
ParsedDraft d,
String source,
String sourceText,
) async {
if (d.type != 'income' && d.type != 'expense') {
throw StateError('请先确认收入或支出类型');
}
return TxApi.create(
categoryId: d.categoryId,
type: d.type,
amount: d.amount,
note: d.note,
paymentMethod: d.paymentMethod,
source: source,
sourceText: sourceText,
);
}
}
class CategoryApi {
static final _dio = ApiClient.instance.dio;
static Future<CategoryItem> create(
String name,
String iconKey,
String colorKey,
String type,
) async {
final payload = {
'name': name,
'iconKey': iconKey,
'colorKey': colorKey,
'type': type,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly) {
final local = LocalDatabase.instance.createCategory(
name,
iconKey,
colorKey,
type,
);
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync(
'category',
local['id'] as int,
'create',
payload,
);
}
return CategoryItem.fromJson(local);
}
try {
final response = await _dio.post('/api/categories', data: payload);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheCategories([json]);
return CategoryItem.fromJson(json);
} catch (error) {
if (!isConnectivityError(error) || !session.isAccount) rethrow;
final local = LocalDatabase.instance.createCategory(
name,
iconKey,
colorKey,
type,
);
LocalDatabase.instance.enqueueSync(
'category',
local['id'] as int,
'create',
payload,
);
return CategoryItem.fromJson(local);
}
}
static Future<CategoryItem> update(
int id, {
required String name,
required String iconKey,
required String colorKey,
required int sortOrder,
}) async {
final payload = {
'name': name,
'iconKey': iconKey,
'colorKey': colorKey,
'sortOrder': sortOrder,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
final local = LocalDatabase.instance.updateCategory(
id,
name,
iconKey,
colorKey,
sortOrder,
);
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
}
return CategoryItem.fromJson(local);
}
final response = await _dio.put('/api/categories/$id', data: payload);
final json = response.data as Map<String, dynamic>;
LocalDatabase.instance.cacheCategories([json]);
return CategoryItem.fromJson(json);
}
static Future<void> reorder(String type, List<int> categoryIds) async {
LocalDatabase.instance.reorderCategories(type, categoryIds);
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || categoryIds.any((id) => id < 0)) {
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
'type': type,
'categoryIds': categoryIds,
});
}
return;
}
await _dio.put(
'/api/categories/reorder',
data: {'type': type, 'categoryIds': categoryIds},
);
}
static Future<void> delete(int id) async {
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || id < 0) {
LocalDatabase.instance.deleteCategory(id);
if (session.isAccount && session.cloudSyncEnabled) {
LocalDatabase.instance.enqueueSync('category', id, 'delete', {
'id': id,
});
}
return;
}
await _dio.delete('/api/categories/$id');
LocalDatabase.instance.deleteCategory(id);
}
}