Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
class ApiClient {
ApiClient._();
static final ApiClient instance = ApiClient._();
static const _storage = FlutterSecureStorage();
static const _tokenKey = 'auth_token';
static final sessionExpired = ValueNotifier<int>(0);
static bool _handlingUnauthorized = false;
static const _internalBuild = bool.fromEnvironment('INTERNAL_BUILD');
static const _internalTestBaseUrl = 'https://lt.frp-say.com:38012';
static const _configuredBaseUrl = String.fromEnvironment('API_BASE_URL');
static const _temporaryFrpHost = 'lt.frp-say.com';
static const _temporaryFrpPort = 38012;
static const _temporaryFrpCertificateSha1 =
'509C3210161E72FB9DA5183D3D2152F63871C31F';
static const String baseUrl = _configuredBaseUrl != ''
? _configuredBaseUrl
: _internalBuild
? _internalTestBaseUrl
: 'https://api.invalid';
static bool get isInternalBuild => _internalBuild;
late final Dio dio = _createDio();
Dio _createDio() {
final client =
Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 120),
),
)
..interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _storage.read(key: _tokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
final unauthorized = error.response?.statusCode == 401;
final data = error.response?.data;
final aiDenied =
error.response?.statusCode == 403 &&
data is Map &&
data['code'] == 'AI_PERMISSION_DENIED';
if (aiDenied) {
await SessionStore.instance.setAiEnabled(false);
}
final isAuthRequest = error.requestOptions.path.startsWith(
'/api/auth/',
);
if (unauthorized && !isAuthRequest && !_handlingUnauthorized) {
_handlingUnauthorized = true;
await SessionStore.instance.markNeedsReauth();
sessionExpired.value++;
Future<void>.delayed(
const Duration(seconds: 1),
() => _handlingUnauthorized = false,
);
}
handler.next(error);
},
),
);
final apiUri = Uri.tryParse(baseUrl);
if (_internalBuild &&
apiUri?.scheme == 'https' &&
apiUri?.host == _temporaryFrpHost &&
apiUri?.port == _temporaryFrpPort) {
client.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final httpClient = HttpClient();
httpClient.badCertificateCallback = (certificate, host, port) {
final certificateSha1 = certificate.sha1
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
return host == _temporaryFrpHost &&
port == _temporaryFrpPort &&
certificateSha1 == _temporaryFrpCertificateSha1;
};
return httpClient;
},
);
}
return client;
}
Future<void> saveToken(String token) =>
_storage.write(key: _tokenKey, value: token);
Future<String?> readToken() => _storage.read(key: _tokenKey);
Future<void> clearToken() => _storage.delete(key: _tokenKey);
}
bool isConnectivityError(Object error) =>
error is DioException &&
(error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.connectionError ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout);
String apiErrorMessage(Object e) {
if (e is OnlineFeatureRequiredException) return e.message;
if (e is StateError) return e.message;
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data['message'] != null)
return data['message'] as String;
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.receiveTimeout) {
return '无法连接服务器,请检查网络';
}
}
return '出错了,请稍后再试';
}
+265
View File
@@ -0,0 +1,265 @@
import 'package:dio/dio.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/local_export_service.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
class AiCompanion {
final String avatarKey;
final String personaKey;
final String? customName;
final int roastLevel, stickerFrequency, proactiveLevel;
AiCompanion.fromJson(Map<String, dynamic> json)
: avatarKey = json['avatarKey'] as String,
personaKey = json['personaKey'] as String,
customName = json['customName'] as String?,
roastLevel = json['roastLevel'] as int,
stickerFrequency = json['stickerFrequency'] as int,
proactiveLevel = json['proactiveLevel'] as int;
}
class UserProfile {
final int userId;
final String username;
final String? nickname;
final String appMode;
final AiCompanion? aiCompanion;
final bool onboardingDone;
final bool aiEnabled;
UserProfile.fromJson(Map<String, dynamic> json)
: userId = (json['userId'] as num).toInt(),
username = json['username'] as String,
nickname = json['nickname'] as String?,
appMode = json['appMode'] as String,
aiCompanion = json['aiCompanion'] == null
? null
: AiCompanion.fromJson(json['aiCompanion'] as Map<String, dynamic>),
onboardingDone = json['onboardingDone'] as bool,
aiEnabled =
((json['permissions'] as Map<String, dynamic>?)?['ai'] as bool?) ??
true;
const UserProfile.local({
required this.userId,
required this.username,
required this.nickname,
required this.appMode,
required this.onboardingDone,
this.aiEnabled = false,
}) : aiCompanion = null;
}
class AuthApi {
static final _dio = ApiClient.instance.dio;
static Future<void> register(
String username,
String password, {
required bool agreedToTerms,
}) async {
final response = await _dio.post(
'/api/auth/register',
data: {
'username': username,
'password': password,
'agreedToTerms': agreedToTerms,
},
);
await ApiClient.instance.saveToken(response.data['token'] as String);
await me();
}
static Future<bool> login(String username, String password) async {
final response = await _dio.post(
'/api/auth/login',
data: {'username': username, 'password': password},
);
await ApiClient.instance.saveToken(response.data['token'] as String);
return response.data['accountClosureCancelled'] as bool? ?? false;
}
static Future<UserProfile> me() async {
final session = SessionStore.instance;
if (session.isGuest || (session.isAccount && session.shouldUseLocalOnly)) {
return _localProfile();
}
try {
final response = await _dio.get('/api/users/me');
final profile = UserProfile.fromJson(
response.data as Map<String, dynamic>,
);
await _cacheProfile(profile);
return profile;
} catch (error) {
if (session.isAccount &&
(isConnectivityError(error) || session.needsReauth)) {
return _localProfile();
}
rethrow;
}
}
static Future<UserProfile> completeOnboarding({
required String appMode,
required String avatarKey,
required String personaKey,
String? customName,
}) async {
SessionStore.instance.requireOnline('完成账号初始化需要连接网络');
final response = await _dio.post(
'/api/users/me/onboarding',
data: {
'appMode': appMode,
'avatarKey': avatarKey,
'personaKey': personaKey,
'customName': customName,
},
);
final profile = UserProfile.fromJson(response.data as Map<String, dynamic>);
await _cacheProfile(profile);
return profile;
}
static Future<UserProfile> switchMode(String appMode) async {
SessionStore.instance.requireOnline('切换 AI 模式需要登录并连接网络');
final response = await _dio.put(
'/api/users/me/mode',
data: {'appMode': appMode},
);
final profile = UserProfile.fromJson(response.data as Map<String, dynamic>);
await _cacheProfile(profile);
return profile;
}
static Future<UserProfile> updateCompanion({
String? avatarKey,
String? personaKey,
String? customName,
int? roastLevel,
int? stickerFrequency,
int? proactiveLevel,
}) async {
SessionStore.instance.requireOnline('AI 伙伴设置需要登录并连接网络');
final response = await _dio.put(
'/api/users/me/companion',
data: {
'avatarKey': avatarKey,
'personaKey': personaKey,
'customName': customName,
'roastLevel': roastLevel,
'stickerFrequency': stickerFrequency,
'proactiveLevel': proactiveLevel,
},
);
final profile = UserProfile.fromJson(response.data as Map<String, dynamic>);
await _cacheProfile(profile);
return profile;
}
static Future<UserProfile> updateProfile(String nickname) async {
final session = SessionStore.instance;
final normalized = nickname.trim();
if (session.isGuest) {
await session.updateNickname(normalized);
return _localProfile();
}
session.requireOnline('修改云端资料需要连接网络');
final previous = session.nickname;
await session.updateNickname(normalized);
try {
final response = await _dio.put(
'/api/users/me/profile',
data: {'nickname': normalized},
);
final profile = UserProfile.fromJson(
response.data as Map<String, dynamic>,
);
await _cacheProfile(profile);
return profile;
} catch (_) {
await session.updateNickname(previous);
rethrow;
}
}
static Future<void> changePassword(
String currentPassword,
String newPassword,
) async {
SessionStore.instance.requireOnline('修改密码需要连接网络');
final response = await _dio.put(
'/api/users/me/password',
data: {'currentPassword': currentPassword, 'newPassword': newPassword},
);
await ApiClient.instance.saveToken(response.data['token'] as String);
}
static Future<List<int>> exportData() async {
if (SessionStore.instance.shouldUseLocalOnly) {
return LocalExportService.buildZip();
}
try {
final response = await _dio.get<List<int>>(
'/api/users/me/export',
options: Options(responseType: ResponseType.bytes),
);
return response.data ?? const [];
} catch (error) {
if (isConnectivityError(error)) return LocalExportService.buildZip();
rethrow;
}
}
static Future<DateTime> requestAccountClosure(
String password,
String confirmationText,
) async {
SessionStore.instance.requireOnline('注销账号需要连接网络');
final response = await _dio.post(
'/api/users/me/closure',
data: {'password': password, 'confirmationText': confirmationText},
);
final scheduledAt = ShanghaiTime.parseCivil(
response.data['scheduledAt'] as String,
);
await logout();
return scheduledAt;
}
static Future<void> deleteAccount(String password) async {
SessionStore.instance.requireOnline('注销账号需要连接网络');
await _dio.delete('/api/users/me', data: {'password': password});
await logout();
}
static Future<void> logout() async {
CurrentLedgerStore.instance.clear();
await ApiClient.instance.clearToken();
await SessionStore.instance.clearActiveSession();
}
static UserProfile _localProfile() {
final session = SessionStore.instance;
return UserProfile.local(
userId: session.userId ?? 0,
username: session.username ?? '游客',
nickname: session.nickname,
appMode: session.isGuest ? 'normal' : session.appMode,
onboardingDone: session.isGuest || session.onboardingDone,
aiEnabled: session.aiEnabled,
);
}
static Future<void> _cacheProfile(UserProfile profile) =>
SessionStore.instance.activateAccount(
userId: profile.userId,
username: profile.username,
nickname: profile.nickname,
appMode: profile.appMode,
onboardingDone: profile.onboardingDone,
aiEnabled: profile.aiEnabled,
);
}
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
class BrandConfig {
final String appName, slogan, logoUrl;
final bool voiceEnabled;
final bool imageEnabled;
final bool screenshotEnabled;
final bool stickersEnabled;
final bool aiAutoBookEnabled;
BrandConfig.fromJson(Map<String, dynamic> j)
: appName = _appName(j['appName']),
slogan = j['slogan'] as String? ?? '',
logoUrl = j['logoUrl'] as String? ?? '',
voiceEnabled = _feature(j, 'voice'),
imageEnabled = _feature(j, 'image'),
screenshotEnabled = _feature(j, 'screenshot'),
stickersEnabled = _feature(j, 'stickers'),
aiAutoBookEnabled = _feature(j, 'aiAutoBook');
static String _appName(Object? value) {
final name = value?.toString().trim();
return name == null || name.isEmpty || name == '喵记账' || name == '喵记'
? '记之'
: name;
}
static bool _feature(Map<String, dynamic> json, String key) {
final features = json['features'];
return features is Map ? features[key] as bool? ?? true : true;
}
}
class AvatarItem {
final String key, defaultName, speechTic;
final String? imageUrl;
AvatarItem.fromJson(Map<String, dynamic> j)
: key = j['key'] as String,
defaultName = j['defaultName'] as String,
speechTic = j['speechTic'] as String? ?? '',
imageUrl = j['imageUrl'] as String?;
}
class PersonaItem {
final String key, name, description, sampleLine;
PersonaItem.fromJson(Map<String, dynamic> j)
: key = j['key'] as String,
name = j['name'] as String,
description = j['description'] as String? ?? '',
sampleLine = j['sampleLine'] as String? ?? '';
}
class CompanionDisplay {
final String name;
final String avatarKey;
const CompanionDisplay({required this.name, required this.avatarKey});
}
class PublicConfigApi {
static final _dio = ApiClient.instance.dio;
static BrandConfig? _cachedBrand;
static Future<void> init() async {
if (!SessionStore.instance.isGuest) {
try {
final b = await _dio.get('/api/public/brand');
_cachedBrand = BrandConfig.fromJson(b.data as Map<String, dynamic>);
} catch (_) {}
}
await refreshCompanion();
}
static String get appName => _cachedBrand?.appName ?? '记之';
static String get slogan => _cachedBrand?.slogan ?? '';
static bool get voiceEnabled => _cachedBrand?.voiceEnabled ?? true;
static bool get imageEnabled => _cachedBrand?.imageEnabled ?? true;
static bool get screenshotEnabled => _cachedBrand?.screenshotEnabled ?? true;
static bool get stickersEnabled => _cachedBrand?.stickersEnabled ?? true;
static bool get aiAutoBookEnabled => _cachedBrand?.aiAutoBookEnabled ?? true;
static final companionNotifier = ValueNotifier<CompanionDisplay>(
const CompanionDisplay(name: 'AI', avatarKey: 'cat'),
);
static String get companionName => companionNotifier.value.name;
static String get companionAvatarKey => companionNotifier.value.avatarKey;
static Future<void> refreshCompanion() async {
final session = SessionStore.instance;
if (!session.isAccount || session.shouldUseLocalOnly) {
companionNotifier.value = const CompanionDisplay(
name: 'AI',
avatarKey: 'cat',
);
return;
}
try {
final responses = await Future.wait([
_dio.get('/api/users/me'),
_dio.get('/api/public/avatars'),
]);
final profile = responses[0].data as Map<String, dynamic>;
final avatars = responses[1].data as List;
final companion = profile['aiCompanion'] as Map<String, dynamic>?;
final selectedAvatarKey = companion?['avatarKey'] as String?;
final customName = _normalizeName(companion?['customName'] as String?);
var avatarKey = selectedAvatarKey;
String? defaultName;
for (final item in avatars) {
final avatar = item as Map<String, dynamic>;
final key = avatar['key'] as String?;
if ((avatarKey == null || avatarKey.isEmpty) &&
key != null &&
key.isNotEmpty) {
avatarKey = key;
}
if (key == selectedAvatarKey) {
defaultName = _normalizeName(avatar['defaultName'] as String?);
break;
}
}
companionNotifier.value = CompanionDisplay(
name: customName ?? defaultName ?? '小账喵',
avatarKey: avatarKey ?? 'cat',
);
} catch (_) {
try {
final a = await _dio.get('/api/public/avatars');
final list = a.data as List;
if (list.isEmpty) return;
final first = list.first as Map<String, dynamic>;
companionNotifier.value = CompanionDisplay(
name: _normalizeName(first['defaultName'] as String?) ?? '小账喵',
avatarKey: first['key'] as String? ?? 'cat',
);
} catch (_) {}
}
}
static Future<List<AvatarItem>> avatars() async {
final res = await _dio.get('/api/public/avatars');
return (res.data as List)
.map((e) => AvatarItem.fromJson(e as Map<String, dynamic>))
.toList();
}
static Future<List<PersonaItem>> personas() async {
final res = await _dio.get('/api/public/personas');
return (res.data as List)
.map((e) => PersonaItem.fromJson(e as Map<String, dynamic>))
.toList();
}
static String? _normalizeName(String? value) {
final text = value?.trim();
if (text == null || text.isEmpty) return null;
return text;
}
}
@@ -0,0 +1,22 @@
class SseFrameAccumulator {
String _pending = '';
List<String> add(String chunk) {
_pending = (_pending + chunk).replaceAll('\r\n', '\n');
final frames = <String>[];
var separator = _pending.indexOf('\n\n');
while (separator >= 0) {
final frame = _pending.substring(0, separator);
_pending = _pending.substring(separator + 2);
if (frame.trim().isNotEmpty) frames.add(frame);
separator = _pending.indexOf('\n\n');
}
return frames;
}
List<String> close() {
final trailing = _pending.trim();
_pending = '';
return trailing.isEmpty ? const [] : [trailing];
}
}