From cc6dc6385a6da143d15a4d7ed413e6eea8e920eb Mon Sep 17 00:00:00 2001 From: andyesfly <35127884@qq.com> Date: Sat, 3 Oct 2026 10:50:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E7=89=88=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=9C=B0=E5=9D=80=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现登录页调试版可见的服务端设置弹窗,支持自定义 IP 和端口,优先级高于编译期默认地址。新增相关状态管理、存储逻辑及设置入口图标,修改 ApiClient 以支持动态传入 Base URL,确保切换后无需重启 App 立即生效。 --- lib/core/env.dart | 27 +- lib/core/network/api_client.dart | 5 +- lib/core/providers.dart | 42 +++- lib/core/storage/token_storage.dart | 17 ++ .../auth/presentation/login_page.dart | 236 ++++++++++++++++++ lib/shared/widgets/x_icon.dart | 5 + 6 files changed, 327 insertions(+), 5 deletions(-) diff --git a/lib/core/env.dart b/lib/core/env.dart index 88fe981..6ecd17e 100644 --- a/lib/core/env.dart +++ b/lib/core/env.dart @@ -2,9 +2,34 @@ /// /// 与 uni-app 版 `utils/request.ts` 的地址策略一致:App 端直连后端。 /// 真机联调时用 `--dart-define=API_BASE_URL=http://<局域网IP>:7001/api` 覆盖默认值。 +/// +/// 登录页「服务端设置」(仅调试版可见)保存的 IP+端口优先级更高, +/// 解析顺序见 [resolveBaseUrl]。 abstract final class Env { + /// 编译期默认地址,作为兜底方案。 static const String apiBaseUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: 'http://192.168.3.29:7001/api', ); -} \ No newline at end of file + + static final Uri _default = Uri.parse(apiBaseUrl); + + /// 默认主机,取自 [apiBaseUrl]。 + static String get defaultHost => _default.host; + + /// 默认端口,取自 [apiBaseUrl],未显式指定时回落 7001。 + static int get defaultPort => _default.hasPort ? _default.port : 7001; + + /// 由 IP + 端口拼接 Base URL;协议与路径沿用 [apiBaseUrl], + /// 这样 `--dart-define` 传入自定义路径时也能保持一致。 + static String buildBaseUrl(String host, String port) { + final h = host.trim(); + final p = port.trim(); + final resolvedPort = p.isEmpty ? '$defaultPort' : p; + return '${_default.scheme}://$h:$resolvedPort${_default.path}'; + } + + /// 当前生效的 Base URL:登录页保存值优先,其次编译期默认值。 + static String resolveBaseUrl({String host = '', String port = ''}) => + host.trim().isEmpty ? apiBaseUrl : buildBaseUrl(host, port); +} diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 5d63a71..a65e2a3 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:dio/dio.dart'; -import '../env.dart'; import '../storage/token_storage.dart'; import 'api_response.dart'; @@ -37,13 +36,13 @@ class UploadResult { /// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断 /// - 401 且提示「过期」时刷新 token 并重试一次,其余 401 直接登出 class ApiClient { - ApiClient({required TokenStorage storage, Dio? dio}) + ApiClient({required TokenStorage storage, required String baseUrl, Dio? dio}) : _storage = storage, _dio = dio ?? Dio( BaseOptions( - baseUrl: Env.apiBaseUrl, + baseUrl: baseUrl, connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 30), sendTimeout: const Duration(seconds: 30), diff --git a/lib/core/providers.dart b/lib/core/providers.dart index 3c06152..5f5d5e6 100644 --- a/lib/core/providers.dart +++ b/lib/core/providers.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'env.dart'; import 'network/api_client.dart'; import 'storage/token_storage.dart'; @@ -8,8 +9,47 @@ final tokenStorageProvider = Provider( (ref) => throw UnimplementedError('tokenStorageProvider 必须在 ProviderScope 中 override'), ); +/// 登录页「服务端设置」保存的主机与端口;空串表示未覆盖,走编译期默认值。 +typedef ServerAddress = ({String host, String port}); + +/// 服务端地址状态。登录页保存/重置后通知 [apiClientProvider] 重建客户端, +/// 因此无需重启 App 即可切换后端。 +class ServerAddressNotifier extends Notifier { + @override + ServerAddress build() { + final storage = ref.watch(tokenStorageProvider); + return (host: storage.serverHost, port: storage.serverPort); + } + + Future save(String host, String port) async { + await ref.read(tokenStorageProvider).setServerAddress(host, port); + state = (host: host, port: port); + } + + Future reset() async { + await ref.read(tokenStorageProvider).clearServerAddress(); + state = (host: '', port: ''); + } +} + +final serverAddressProvider = + NotifierProvider( + ServerAddressNotifier.new, + ); + +/// 当前生效的 API Base URL。 +final apiBaseUrlProvider = Provider( + (ref) => Env.resolveBaseUrl( + host: ref.watch(serverAddressProvider).host, + port: ref.watch(serverAddressProvider).port, + ), +); + final apiClientProvider = Provider((ref) { - final client = ApiClient(storage: ref.watch(tokenStorageProvider)); + final client = ApiClient( + storage: ref.watch(tokenStorageProvider), + baseUrl: ref.watch(apiBaseUrlProvider), + ); ref.onDispose(client.dispose); return client; }); \ No newline at end of file diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 1630b97..0fded74 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -12,6 +12,8 @@ class TokenStorage { static const _kAccessToken = 'accessToken'; static const _kRefreshToken = 'refreshToken'; static const _kUserInfo = 'userInfo'; + static const _kServerHost = 'serverHost'; + static const _kServerPort = 'serverPort'; static Future open() async => TokenStorage(await SharedPreferences.getInstance()); @@ -39,6 +41,21 @@ class TokenStorage { Future setUserInfo(Map info) => _prefs.setString(_kUserInfo, jsonEncode(info)); + /// 登录页「服务端设置」保存的 IP 与端口,空串表示未覆盖、走编译期默认值。 + String get serverHost => _prefs.getString(_kServerHost) ?? ''; + + String get serverPort => _prefs.getString(_kServerPort) ?? ''; + + Future setServerAddress(String host, String port) async { + await _prefs.setString(_kServerHost, host); + await _prefs.setString(_kServerPort, port); + } + + Future clearServerAddress() async { + await _prefs.remove(_kServerHost); + await _prefs.remove(_kServerPort); + } + Future clearSession() async { await _prefs.remove(_kAccessToken); await _prefs.remove(_kRefreshToken); diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart index 3351a12..29b3b02 100644 --- a/lib/features/auth/presentation/login_page.dart +++ b/lib/features/auth/presentation/login_page.dart @@ -1,10 +1,14 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/env.dart'; +import '../../../core/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_typography.dart'; +import '../../../shared/widgets/app_feedback.dart'; import '../../../shared/widgets/x_card.dart'; import '../../../shared/widgets/x_common.dart'; import '../../../shared/widgets/x_icon.dart'; @@ -107,11 +111,45 @@ class _LoginPageState extends ConsumerState { ), ), ), + // 测试用「服务端设置」入口,仅调试版可见 + if (kDebugMode) + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: GestureDetector( + onTap: _openServerSettings, + behavior: HitTestBehavior.opaque, + child: const Padding( + padding: EdgeInsets.all(12), + child: XIcon( + 'settings', + size: 22, + color: AppColors.textMuted, + ), + ), + ), + ), + ), ], ), ); } + Future _openServerSettings() { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppRadius.lg), + ), + ), + builder: (_) => const _ServerSettingsSheet(), + ); + } + Widget _buildBrand() { return Column( children: [ @@ -276,4 +314,202 @@ class _SoftBlob extends StatelessWidget { ), ); } +} + +/// 调试版「服务端设置」弹窗:只让改 IP 与端口, +/// 协议与路径沿用 [Env.apiBaseUrl] 的编译期配置。 +class _ServerSettingsSheet extends ConsumerStatefulWidget { + const _ServerSettingsSheet(); + + @override + ConsumerState<_ServerSettingsSheet> createState() => + _ServerSettingsSheetState(); +} + +class _ServerSettingsSheetState extends ConsumerState<_ServerSettingsSheet> { + late final TextEditingController _host; + late final TextEditingController _port; + String _error = ''; + + @override + void initState() { + super.initState(); + // 已保存过就回填保存值,否则预填编译期默认值 + final saved = ref.read(serverAddressProvider); + _host = TextEditingController( + text: saved.host.isEmpty ? Env.defaultHost : saved.host, + ); + _port = TextEditingController( + text: saved.port.isEmpty ? '${Env.defaultPort}' : saved.port, + ); + } + + @override + void dispose() { + _host.dispose(); + _port.dispose(); + super.dispose(); + } + + String get _preview { + final host = _host.text.trim(); + return Env.buildBaseUrl( + host.isEmpty ? Env.defaultHost : host, + _port.text.trim(), + ); + } + + Future _save() async { + final host = _host.text.trim(); + final port = _port.text.trim(); + + if (host.isEmpty) { + setState(() => _error = '请输入服务端 IP'); + return; + } + if (host.contains('://') || host.contains('/') || host.contains(':')) { + setState(() => _error = '只填 IP 或域名,不要带 http:// 和端口'); + return; + } + final parsedPort = int.tryParse(port); + if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) { + setState(() => _error = '端口需为 1 - 65535 的整数'); + return; + } + + // 保存后 serverAddressProvider 变化会重建 ApiClient,立即生效 + await ref.read(serverAddressProvider.notifier).save(host, port); + if (!mounted) return; + Navigator.of(context).pop(); + showAppToast('已切换到 ${Env.buildBaseUrl(host, port)}', success: true); + } + + Future _reset() async { + await ref.read(serverAddressProvider.notifier).reset(); + if (!mounted) return; + Navigator.of(context).pop(); + showAppToast('已恢复默认地址', success: true); + } + + @override + Widget build(BuildContext context) { + return Padding( + // 键盘弹出时把弹窗顶上去 + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('服务端设置', style: AppTypography.titleSm), + const SizedBox(height: 6), + const Text( + '仅调试版可见。保存后立即生效,无需重启 App。', + style: TextStyle(fontSize: 13, color: AppColors.textMuted), + ), + const SizedBox(height: 20), + _buildField( + label: '服务端 IP', + controller: _host, + hintText: '如 192.168.3.29', + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + _buildField( + label: '端口', + controller: _port, + hintText: '如 7001', + keyboardType: TextInputType.number, + ), + const SizedBox(height: 14), + // 保存前实时预览拼接结果,避免地址拼错 + ListenableBuilder( + listenable: Listenable.merge([_host, _port]), + builder: (context, _) => Text( + '生效地址:$_preview', + style: AppTypography.dataMono.copyWith( + color: AppColors.textMuted, + ), + ), + ), + if (_error.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + _error, + style: const TextStyle( + fontSize: 13, + color: AppColors.statusError, + ), + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: XPrimaryButton( + label: '恢复默认', + onPressed: _reset, + background: AppColors.surfaceBright, + foreground: AppColors.onSurfaceVariant, + borderColor: AppColors.borderRule, + ), + ), + const SizedBox(width: 12), + Expanded( + child: XPrimaryButton(label: '保存', onPressed: _save), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildField({ + required String label, + required TextEditingController controller, + required String hintText, + required TextInputType keyboardType, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: AppTypography.labelBold.copyWith( + color: AppColors.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: AppColors.surfaceBright, + border: Border.all(color: AppColors.borderRule), + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: TextField( + controller: controller, + keyboardType: keyboardType, + autocorrect: false, + style: const TextStyle(fontSize: 15, color: AppColors.onSurface), + cursorColor: AppColors.primary, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: hintText, + hintStyle: const TextStyle( + fontSize: 15, + color: AppColors.textPlaceholder, + ), + ), + ), + ), + ], + ); + } } \ No newline at end of file diff --git a/lib/shared/widgets/x_icon.dart b/lib/shared/widgets/x_icon.dart index eea4a0d..dbe7f1a 100644 --- a/lib/shared/widgets/x_icon.dart +++ b/lib/shared/widgets/x_icon.dart @@ -21,6 +21,9 @@ class XIcon extends StatelessWidget { /// 形状与 XIcon.vue 的 `ICONS` 一一对应,未做任何视觉改动。 /// `currentColor` 在生成 SVG 时替换为 [color] 的实际色值。 + /// + /// 例外:`settings` 是 Flutter 端为调试版「服务端设置」入口新增的齿轮, + /// 源项目图标表里没有,风格与其余描边图标保持一致。 static const Map paths = { 'dashboard': '', @@ -76,6 +79,8 @@ class XIcon extends StatelessWidget { '', 'close': '', + 'settings': + '', }; static final Map _svgCache = {};