feat: 添加调试版服务端地址切换功能

实现登录页调试版可见的服务端设置弹窗,支持自定义 IP 和端口,优先级高于编译期默认地址。新增相关状态管理、存储逻辑及设置入口图标,修改 ApiClient 以支持动态传入 Base URL,确保切换后无需重启 App 立即生效。
This commit is contained in:
andyesfly committed 2026-10-03 10:50:12 +08:00
1 parent e14d371cc8
commit cc6dc6385a
6 files changed
+326 -4

No files matched your search

+25
View File
@@ -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',
);
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);
}
+2 -3
View File
@@ -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),
+41 -1
View File
@@ -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<TokenStorage>(
(ref) => throw UnimplementedError('tokenStorageProvider 必须在 ProviderScope 中 override'),
);
/// 登录页「服务端设置」保存的主机与端口;空串表示未覆盖,走编译期默认值。
typedef ServerAddress = ({String host, String port});
/// 服务端地址状态。登录页保存/重置后通知 [apiClientProvider] 重建客户端,
/// 因此无需重启 App 即可切换后端。
class ServerAddressNotifier extends Notifier<ServerAddress> {
@override
ServerAddress build() {
final storage = ref.watch(tokenStorageProvider);
return (host: storage.serverHost, port: storage.serverPort);
}
Future<void> save(String host, String port) async {
await ref.read(tokenStorageProvider).setServerAddress(host, port);
state = (host: host, port: port);
}
Future<void> reset() async {
await ref.read(tokenStorageProvider).clearServerAddress();
state = (host: '', port: '');
}
}
final serverAddressProvider =
NotifierProvider<ServerAddressNotifier, ServerAddress>(
ServerAddressNotifier.new,
);
/// 当前生效的 API Base URL。
final apiBaseUrlProvider = Provider<String>(
(ref) => Env.resolveBaseUrl(
host: ref.watch(serverAddressProvider).host,
port: ref.watch(serverAddressProvider).port,
),
);
final apiClientProvider = Provider<ApiClient>((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;
});
+17
View File
@@ -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<TokenStorage> open() async =>
TokenStorage(await SharedPreferences.getInstance());
@@ -39,6 +41,21 @@ class TokenStorage {
Future<void> setUserInfo(Map<String, dynamic> info) =>
_prefs.setString(_kUserInfo, jsonEncode(info));
/// 登录页「服务端设置」保存的 IP 与端口,空串表示未覆盖、走编译期默认值。
String get serverHost => _prefs.getString(_kServerHost) ?? '';
String get serverPort => _prefs.getString(_kServerPort) ?? '';
Future<void> setServerAddress(String host, String port) async {
await _prefs.setString(_kServerHost, host);
await _prefs.setString(_kServerPort, port);
}
Future<void> clearServerAddress() async {
await _prefs.remove(_kServerHost);
await _prefs.remove(_kServerPort);
}
Future<void> clearSession() async {
await _prefs.remove(_kAccessToken);
await _prefs.remove(_kRefreshToken);
@@ -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<LoginPage> {
),
),
),
// 测试用「服务端设置」入口,仅调试版可见
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<void> _openServerSettings() {
return showModalBottomSheet<void>(
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: [
@@ -277,3 +315,201 @@ 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<void> _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<void> _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,
),
),
),
),
],
);
}
}
+5
View File
@@ -21,6 +21,9 @@ class XIcon extends StatelessWidget {
/// 形状与 XIcon.vue 的 `ICONS` 一一对应,未做任何视觉改动。
/// `currentColor` 在生成 SVG 时替换为 [color] 的实际色值。
///
/// 例外:`settings` 是 Flutter 端为调试版「服务端设置」入口新增的齿轮,
/// 源项目图标表里没有,风格与其余描边图标保持一致。
static const Map<String, String> paths = {
'dashboard':
'<rect x="3" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5"/>',
@@ -76,6 +79,8 @@ class XIcon extends StatelessWidget {
'<path d="M4 17l5-5 3 3 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M13 9h5v5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'close':
'<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
'settings':
'<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></g>',
};
static final Map<String, String> _svgCache = {};