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
+7
View File
@@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
/// 全局 navigatorKey
///
/// 供没有 BuildContext 的地方使用(网络层 401/403 跳转、全局 toast)。
/// 独立成文件是为了避免 app.dart ↔ 网络层 之间的循环依赖。
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/foundation.dart';
/// API 环境配置
///
/// ## 开发环境(按平台自动选择)
/// - **Android 真机/模拟器**:`http://localhost:12345/api`
/// 需要先做端口转发,把手机的 localhost 映射到电脑的 localhost:
/// ```
/// adb reverse tcp:12345 tcp:12345 # 每次插拔设备后需重新执行
/// adb reverse --list # 确认映射存在
/// ```
/// 好处:不受 Windows 防火墙影响,也不要求手机与电脑在同一 Wi-Fi。
/// 若不想用 adb reverse(仅模拟器可行),可用电脑局域网 IP 覆盖:
/// `flutter run --dart-define=API_BASE_URL=http://192.168.x.x:12345/api`
/// (模拟器也可直接用固定地址 `10.0.2.2:12345`)
/// - **Windows / Chrome / iOS 模拟器**:`http://localhost:12345/api`
///
/// ## 生产环境
/// HTTPS,无需关心明文流量问题。
const String kProdBaseUrl = 'https://accounting.aqroid.cn/api';
const String kLocalBaseUrl = 'http://localhost:12345/api';
/// 手动覆盖地址(优先级最高):
/// `flutter run --dart-define=API_BASE_URL=http://<host>:12345/api`
const String _override = String.fromEnvironment('API_BASE_URL');
String get baseUrl {
if (_override.isNotEmpty) return _override;
if (!kDebugMode) return kProdBaseUrl;
return kLocalBaseUrl;
}
+16
View File
@@ -0,0 +1,16 @@
/// 统一 API 异常(对齐 uniApp request.js 的错误语义)
class ApiException implements Exception {
final int code;
final String message;
ApiException(this.code, this.message);
@override
String toString() => 'ApiException($code, $message)';
}
/// 从任意抛出对象中提取 ApiException(供 UI/Provider 捕获使用)
ApiException asApiException(Object error) {
if (error is ApiException) return error;
return ApiException(-1, error.toString());
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:dio/dio.dart';
import '../storage/local_storage.dart';
/// 请求拦截器:注入 Bearer token(对齐 uniApp request.js 的 header 逻辑)
class AuthInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = LocalStorage.instance.getToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation/navigator_key.dart';
import '../../pages/login/login_page.dart';
import '../../providers/auth_provider.dart';
/// 认证失效处理桥接:网络层(无 Riverpod 上下文)通过此模块
/// 调用 authProvider.logout 并跳转登录页。
ProviderContainer? _container;
void configureAuthRedirect(ProviderContainer container) {
_container = container;
}
/// 主动退出登录(用户在「我的」页面点击)
Future<void> logoutAndGoLogin() async {
await _container?.read(authProvider.notifier).logout();
_goLoginPage();
}
/// 登录已失效(token 过期/无效):清本地登录态并跳登录页,给出提示
Future<void> forceLogout({String reason = '登录已过期,请重新登录'}) async {
await _container?.read(authProvider.notifier).logout();
_goLoginPage();
_showTip(reason);
}
void _goLoginPage() {
final nav = navigatorKey.currentState;
if (nav == null) return;
nav.pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginPage()),
(route) => false,
);
}
void _showTip(String reason) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = navigatorKey.currentContext;
if (ctx == null) return;
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(reason),
backgroundColor: const Color(0xFFB71C1C),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
),
);
});
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api_env.dart';
import 'auth_interceptor.dart';
import 'error_interceptor.dart';
/// 全局 Dio 实例
final dioProvider = Provider<Dio>((ref) {
final dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
));
dio.interceptors.addAll([
AuthInterceptor(),
ErrorInterceptor(),
]);
return dio;
});
+108
View File
@@ -0,0 +1,108 @@
import 'package:dio/dio.dart';
import 'api_exception.dart';
import 'auth_redirect.dart';
/// 统一响应处理:业务 code 解包、登录失效跳转、错误映射为 ApiException。
///
/// dio 默认 validateStatus(<400) 下:HTTP 2xx → onResponse(解包业务 code),
/// HTTP 4xx/5xx → onError(提取 message / 处理认证失效)。
///
/// ## 关于「登录失效」的判定(重要)
/// 后端 `SecurityConfig` 未配置 authenticationEntryPoint,Spring Security 6
/// 默认用 Http403ForbiddenEntryPoint,因此 **token 缺失/过期时返回的是 403 而非 401**。
/// 所以这里两种都要处理:
/// - 401:标准未授权(后端以后改对了也兼容)
/// - 403:仅当请求的是需要鉴权的接口(非 permitAll 路径)时才算登录失效
///
/// permitAll 路径(见后端 SecurityConfig):`/auth/**`、`/gold-price/**`。
/// 这些接口的 403 属于服务端错误(例如登录时数据库连不上),绝不能当成过期——
/// 否则会把用户无故踢下线。
class ErrorInterceptor extends Interceptor {
bool _redirecting = false;
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final data = response.data;
// body.code == 200 或不存在 code 字段视为成功(与 uniApp request.js 一致)
if (data is Map && data['code'] != null && data['code'] != 200) {
final code = data['code'] is int ? data['code'] as int : -1;
final message = data['message']?.toString() ?? '请求失败';
if (code == 401) {
_handleAuthFailure();
handler.reject(_dioException(response.requestOptions, ApiException(401, message)));
return;
}
handler.reject(_dioException(response.requestOptions, ApiException(code, message)));
return;
}
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// 已包装为 ApiException 的直接透传
if (err.error is ApiException) {
handler.next(err);
return;
}
final status = err.response?.statusCode;
final path = err.response?.requestOptions.path ?? err.requestOptions.path;
// 401:标准未授权 → 视为登录失效
if (status == 401) {
_handleAuthFailure();
handler.next(err.copyWith(error: ApiException(401, '登录已过期,请重新登录')));
return;
}
// 403:本后端未认证时的兜底状态码 → 受保护接口的 403 视为登录失效
if (status == 403 && !_isPermitAllPath(path)) {
_handleAuthFailure();
handler.next(err.copyWith(error: ApiException(403, '登录已过期,请重新登录')));
return;
}
// HTTP 4xx:尽量提取后端返回的 message(含 permitAll 接口的真实服务端错误)
final data = err.response?.data;
if (data is Map && data['message'] != null) {
handler.next(err.copyWith(error: ApiException(-1, data['message'].toString())));
return;
}
// 403 且无 message:多为 Spring Security 兜底(空响应体),给出可读提示
if (status == 403) {
handler.next(err.copyWith(error: ApiException(403, '请求被拒绝(403),请查看后端日志')));
return;
}
// 网络错误等统一映射
final message = switch (err.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
'连接超时,请检查网络',
DioExceptionType.connectionError => '网络错误,请检查网络连接',
_ => '请求失败(${err.response?.statusCode ?? '无响应'})',
};
handler.next(err.copyWith(error: ApiException(-1, message)));
}
/// 后端 SecurityConfig 中 permitAll 的路径前缀,其 403 不代表登录失效
bool _isPermitAllPath(String path) =>
path.startsWith('/auth') || path.startsWith('/gold-price');
void _handleAuthFailure() {
if (_redirecting) return;
_redirecting = true;
Future(() async {
try {
await forceLogout();
} finally {
Future.delayed(const Duration(seconds: 1), () => _redirecting = false);
}
});
}
DioException _dioException(RequestOptions options, ApiException error) =>
DioException(requestOptions: options, error: error);
}
+73
View File
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// 本地存储封装:token / userInfo / 记住密码
/// (对齐 uniApp 的 uni.setStorageSync / getStorageSync 用法)
class LocalStorage {
LocalStorage._();
static final LocalStorage instance = LocalStorage._();
SharedPreferences? _prefs;
/// App 启动时调用一次,确保后续同步读取可用
Future<void> init() async {
_prefs ??= await SharedPreferences.getInstance();
}
Future<SharedPreferences> _getPrefs() async {
return _prefs ??= await SharedPreferences.getInstance();
}
// ---- 认证 ----
static const _keyToken = 'token';
static const _keyUserInfo = 'userInfo';
Future<void> setAuth({required String token, required Map<String, dynamic> userInfo}) async {
final prefs = await _getPrefs();
await prefs.setString(_keyToken, token);
await prefs.setString(_keyUserInfo, jsonEncode(userInfo));
}
Future<void> clearAuth() async {
final prefs = await _getPrefs();
await prefs.remove(_keyToken);
await prefs.remove(_keyUserInfo);
}
String? getToken() => _prefs?.getString(_keyToken);
Map<String, dynamic>? getUserInfo() {
final raw = _prefs?.getString(_keyUserInfo);
if (raw == null) return null;
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
// ---- 记住密码 ----
static const _keySavedUsername = 'saved_username';
static const _keySavedPassword = 'saved_password';
Future<void> saveRememberedPassword(String username, String encryptedPassword) async {
final prefs = await _getPrefs();
await prefs.setString(_keySavedUsername, username);
await prefs.setString(_keySavedPassword, encryptedPassword);
}
Future<void> clearRememberedPassword() async {
final prefs = await _getPrefs();
await prefs.remove(_keySavedUsername);
await prefs.remove(_keySavedPassword);
}
({String username, String password})? getRememberedAccount() {
final username = _prefs?.getString(_keySavedUsername);
final password = _prefs?.getString(_keySavedPassword);
if (username == null || password == null) return null;
return (username: username, password: password);
}
}