feat: 添加调试版服务端地址切换功能
实现登录页调试版可见的服务端设置弹窗,支持自定义 IP 和端口,优先级高于编译期默认地址。新增相关状态管理、存储逻辑及设置入口图标,修改 ApiClient 以支持动态传入 Base URL,确保切换后无需重启 App 立即生效。
This commit is contained in:
1 parent
e14d371cc8
commit
cc6dc6385a
6 files changed
+326
-4
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 = {};
|
||||
|
||||
Reference in new issue
Block a user