- BLE: 修复 BlueZ LocalName 与 Includes 冲突,串行注册与退避 - HTTP: 语音对话补 Live2D talking 标志与 session;Web 补 X-Session-Id - Flutter: 角色/对话/模型页 + API,Gradle/依赖升级,麦克风权限 - 文档: 新增 docs/STATUS.md,校准 CLAUDE/PROGRESS/README - 清理: 移除根目录截图与临时模型,运维脚本迁入 scripts/device
679 lines
20 KiB
Dart
679 lines
20 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:http/http.dart' as http;
|
||
|
||
import '../models/ai_model.dart';
|
||
import '../models/api_response.dart';
|
||
import '../models/ble_status.dart';
|
||
import '../models/character_pack.dart';
|
||
import '../models/chat_models.dart';
|
||
import '../models/player_status.dart';
|
||
import '../models/video_item.dart';
|
||
import '../models/wifi_network.dart';
|
||
import '../models/wifi_status.dart';
|
||
|
||
typedef UploadProgressCallback = void Function(double progress);
|
||
|
||
class HttpApiService {
|
||
HttpApiService({required String baseUrl, http.Client? client})
|
||
: _baseUrl = normalizeBaseUrl(baseUrl),
|
||
_client = client ?? http.Client();
|
||
|
||
final http.Client _client;
|
||
String _baseUrl;
|
||
|
||
String get baseUrl => _baseUrl;
|
||
|
||
set baseUrl(String value) {
|
||
_baseUrl = normalizeBaseUrl(value);
|
||
}
|
||
|
||
Uri _uri(String path, [Map<String, String>? queryParameters]) {
|
||
final normalizedPath = path.startsWith('/') ? path : '/$path';
|
||
return Uri.parse('$_baseUrl$normalizedPath').replace(
|
||
queryParameters: queryParameters == null || queryParameters.isEmpty
|
||
? null
|
||
: queryParameters,
|
||
);
|
||
}
|
||
|
||
Future<String> fetchConsolePage() async {
|
||
final response = await _client.get(_uri('/'));
|
||
_ensureSuccess(response);
|
||
return response.body;
|
||
}
|
||
|
||
Future<String> fetchConsoleIndexHtml() async {
|
||
final response = await _client.get(_uri('/index.html'));
|
||
_ensureSuccess(response);
|
||
return response.body;
|
||
}
|
||
|
||
Future<PlayerStatus> getStatus() => getPlaybackStatus();
|
||
|
||
Future<PlayerStatus> getPlaybackStatus() async {
|
||
final response = await _client.get(_uri('/api/status'));
|
||
_ensureSuccess(response);
|
||
return PlayerStatus.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<ApiResponse> play() => _postCommand('/api/play');
|
||
|
||
Future<ApiResponse> pause() => _postCommand('/api/pause');
|
||
|
||
Future<ApiResponse> next() => _postCommand('/api/next');
|
||
|
||
Future<ApiResponse> previous() => _postCommand('/api/previous');
|
||
|
||
Future<ApiResponse> goto(int index) => _postCommand('/api/goto/$index');
|
||
|
||
Future<ApiResponse> gotoIndex(int index) => goto(index);
|
||
|
||
Future<List<String>> getPlaylist() async {
|
||
final response = await _client.get(_uri('/api/playlist'));
|
||
_ensureSuccess(response);
|
||
final decoded = _decodeJson(response.body);
|
||
if (decoded is! List) {
|
||
return const <String>[];
|
||
}
|
||
|
||
return decoded.map<String>((dynamic item) {
|
||
if (item is String) {
|
||
return item;
|
||
}
|
||
if (item is Map<String, dynamic>) {
|
||
return item['name']?.toString() ?? jsonEncode(item);
|
||
}
|
||
if (item is Map) {
|
||
return item['name']?.toString() ?? jsonEncode(item);
|
||
}
|
||
return item.toString();
|
||
}).toList(growable: false);
|
||
}
|
||
|
||
Future<ApiResponse> changeScene(String name) {
|
||
return _postCommand('/api/scene/${Uri.encodeComponent(name)}');
|
||
}
|
||
|
||
Future<ApiResponse> switchScene(String name) => changeScene(name);
|
||
|
||
Future<ApiResponse> trigger(String name, [String? value]) {
|
||
final suffix = value == null || value.isEmpty
|
||
? ''
|
||
: '/${Uri.encodeComponent(value)}';
|
||
return _postCommand('/api/trigger/${Uri.encodeComponent(name)}$suffix');
|
||
}
|
||
|
||
Future<ApiResponse> triggerEvent(String name, {String? value}) {
|
||
return trigger(name, value);
|
||
}
|
||
|
||
Future<WifiStatus> getWifiStatus() async {
|
||
final response = await _client.get(_uri('/api/wifi/status'));
|
||
_ensureSuccess(response);
|
||
return WifiStatus.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<List<WifiNetwork>> scanWifi() async {
|
||
try {
|
||
final response = await _client.get(_uri('/api/wifi/scan'));
|
||
_ensureSuccess(response);
|
||
return _decodeWifiNetworks(response.body);
|
||
} on ApiException {
|
||
final response = await _client.post(_uri('/api/wifi/scan'));
|
||
_ensureSuccess(response);
|
||
return _decodeWifiNetworks(response.body);
|
||
}
|
||
}
|
||
|
||
Future<List<WifiNetwork>> scanWifiViaGet() => scanWifi();
|
||
|
||
Future<List<WifiNetwork>> scanWifiViaPost() => scanWifi();
|
||
|
||
Future<ApiResponse> connectWifi(String ssid, String password) async {
|
||
final response = await _client.post(
|
||
_uri('/api/wifi/connect'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(<String, dynamic>{
|
||
'ssid': ssid,
|
||
'password': password,
|
||
}),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<ApiResponse> startAP([String? ssid, String? password]) async {
|
||
final body = <String, dynamic>{
|
||
if (ssid != null && ssid.isNotEmpty) 'ssid': ssid,
|
||
if (password != null && password.isNotEmpty) 'password': password,
|
||
};
|
||
|
||
try {
|
||
final response = await _client.post(
|
||
_uri('/api/wifi/ap/start'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(body),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
} on ApiException {
|
||
final response = await _client.post(
|
||
_uri('/api/wifi/hotspot/start'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(body),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
}
|
||
|
||
Future<ApiResponse> startAccessPoint({String? ssid, String? password}) {
|
||
return startAP(ssid, password);
|
||
}
|
||
|
||
Future<ApiResponse> startHotspot({String? ssid, String? password}) {
|
||
return startAP(ssid, password);
|
||
}
|
||
|
||
Future<ApiResponse> stopAP() async {
|
||
try {
|
||
return await _postCommand('/api/wifi/ap/stop');
|
||
} on ApiException {
|
||
return _postCommand('/api/wifi/hotspot/stop');
|
||
}
|
||
}
|
||
|
||
Future<ApiResponse> stopAccessPoint() => stopAP();
|
||
|
||
Future<ApiResponse> stopHotspot() => stopAP();
|
||
|
||
Future<BleServiceStatus> getBleStatus() async {
|
||
final response = await _client.get(_uri('/api/ble/status'));
|
||
_ensureSuccess(response);
|
||
return BleServiceStatus.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<ApiResponse> startBle([String? deviceName]) async {
|
||
final response = await _client.post(
|
||
_uri('/api/ble/start'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(<String, dynamic>{
|
||
if (deviceName != null && deviceName.isNotEmpty)
|
||
'device_name': deviceName,
|
||
}),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<ApiResponse> stopBle() => _postCommand('/api/ble/stop');
|
||
|
||
Future<List<VideoItem>> getVideos() async {
|
||
final response = await _client.get(_uri('/api/videos'));
|
||
_ensureSuccess(response);
|
||
final decoded = _decodeJson(response.body);
|
||
if (decoded is! List) {
|
||
return const <VideoItem>[];
|
||
}
|
||
|
||
return decoded.map<VideoItem>((dynamic item) {
|
||
if (item is Map<String, dynamic>) {
|
||
return VideoItem.fromJson(item);
|
||
}
|
||
if (item is Map) {
|
||
return VideoItem.fromJson(Map<String, dynamic>.from(item));
|
||
}
|
||
return const VideoItem(name: '', size: 0);
|
||
}).where((item) => item.name.isNotEmpty).toList(growable: false);
|
||
}
|
||
|
||
Future<ApiResponse> uploadVideo(File file) {
|
||
return _uploadSingleFile('/api/videos/upload', file);
|
||
}
|
||
|
||
Future<ApiResponse> uploadVideoWithProgress(
|
||
File file, {
|
||
UploadProgressCallback? onProgress,
|
||
}) {
|
||
return _uploadSingleFile(
|
||
'/api/videos/upload',
|
||
file,
|
||
onProgress: onProgress,
|
||
);
|
||
}
|
||
|
||
Future<ApiResponse> uploadVideos(List<String> filePaths) async {
|
||
if (filePaths.isEmpty) {
|
||
throw const ApiException('未选择上传文件');
|
||
}
|
||
|
||
final request = http.MultipartRequest('POST', _uri('/api/videos/upload'));
|
||
for (final filePath in filePaths) {
|
||
request.files.add(await http.MultipartFile.fromPath('file', filePath));
|
||
}
|
||
|
||
final response = await http.Response.fromStream(await request.send());
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<ApiResponse> deleteVideo(String name) {
|
||
return _deleteCommand('/api/videos/${Uri.encodeComponent(name)}');
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getConfig() async {
|
||
final response = await _client.get(_uri('/api/config'));
|
||
_ensureSuccess(response);
|
||
return _decodeMap(response.body);
|
||
}
|
||
|
||
Future<ApiResponse> updateConfig(Map<String, dynamic> config) async {
|
||
final response = await _client.post(
|
||
_uri('/api/config'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(config),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getDisplayConfig() async {
|
||
final response = await _client.get(_uri('/api/config/display'));
|
||
_ensureSuccess(response);
|
||
return _decodeMap(response.body);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getAvailableConfigs() async {
|
||
final response = await _client.get(_uri('/api/config/available'));
|
||
_ensureSuccess(response);
|
||
return _decodeMap(response.body);
|
||
}
|
||
|
||
Future<ApiResponse> switchConfig(String filename) async {
|
||
final response = await _client.post(
|
||
_uri('/api/config/switch'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(<String, dynamic>{'filename': filename}),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<AvailableConfigs> getAvailableCharacterConfigs() async {
|
||
final map = await getAvailableConfigs();
|
||
return AvailableConfigs.fromJson(map);
|
||
}
|
||
|
||
// ── M2.1 AI 对话 / 模型管理 ──
|
||
|
||
Future<ChatTurnResponse> chatText({
|
||
required String text,
|
||
String? sessionId,
|
||
}) async {
|
||
final response = await _client.post(
|
||
_uri('/api/chat/text'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(<String, dynamic>{
|
||
'text': text,
|
||
if (sessionId != null && sessionId.isNotEmpty) 'session_id': sessionId,
|
||
}),
|
||
);
|
||
// AI 错误也以 JSON ChatResponse 返回(可能是 5xx)
|
||
final map = _decodeMap(response.body);
|
||
final result = ChatTurnResponse.fromJson(map);
|
||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||
return result;
|
||
}
|
||
if (result.error != null && result.error!.isNotEmpty) {
|
||
return result;
|
||
}
|
||
throw ApiException(
|
||
map['message']?.toString() ?? '对话失败 (${response.statusCode})',
|
||
statusCode: response.statusCode,
|
||
);
|
||
}
|
||
|
||
Future<ChatTurnResponse> chatAudio({
|
||
required List<int> bytes,
|
||
String format = 'wav',
|
||
String? sessionId,
|
||
}) async {
|
||
final response = await _client.post(
|
||
_uri('/api/chat/audio'),
|
||
headers: <String, String>{
|
||
'Content-Type': 'application/octet-stream',
|
||
'Accept': 'application/json',
|
||
'X-Audio-Format': format,
|
||
if (sessionId != null && sessionId.isNotEmpty) 'X-Session-Id': sessionId,
|
||
},
|
||
body: bytes,
|
||
);
|
||
final map = _decodeMap(response.body);
|
||
final result = ChatTurnResponse.fromJson(map);
|
||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||
return result;
|
||
}
|
||
if (result.error != null && result.error!.isNotEmpty) {
|
||
return result;
|
||
}
|
||
throw ApiException(
|
||
map['message']?.toString() ?? '语音对话失败 (${response.statusCode})',
|
||
statusCode: response.statusCode,
|
||
);
|
||
}
|
||
|
||
String chatAudioFileUrl(String absolutePath) {
|
||
return _uri(
|
||
'/api/chat/audio/file',
|
||
<String, String>{'path': absolutePath},
|
||
).toString();
|
||
}
|
||
|
||
Future<ModelsSnapshot> getModels() async {
|
||
final response = await _client.get(_uri('/api/models'));
|
||
_ensureSuccess(response);
|
||
return ModelsSnapshot.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<ApiResponse> downloadModel(String modelId) async {
|
||
final response = await _client.post(
|
||
_uri('/api/models/download'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(<String, dynamic>{'model_id': modelId}),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<ApiResponse> switchModel({
|
||
required String modelId,
|
||
required String kind,
|
||
}) async {
|
||
final response = await _client.post(
|
||
_uri('/api/models/switch'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(<String, dynamic>{
|
||
'model_id': modelId,
|
||
'kind': kind,
|
||
}),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<ApiResponse> deleteModel(String modelId) async {
|
||
final response = await _client.post(
|
||
_uri('/api/models/delete'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(<String, dynamic>{'model_id': modelId}),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> listFiles(String dirKey, [String? path]) async {
|
||
final response = await _client.get(
|
||
_uri('/api/files/$dirKey', _pathQuery(path)),
|
||
);
|
||
_ensureSuccess(response);
|
||
final decoded = _decodeJson(response.body);
|
||
if (decoded is! List) {
|
||
return const <Map<String, dynamic>>[];
|
||
}
|
||
|
||
return decoded
|
||
.whereType<Map>()
|
||
.map((entry) => Map<String, dynamic>.from(entry))
|
||
.toList(growable: false);
|
||
}
|
||
|
||
Future<ApiResponse> uploadFile(String dirKey, File file, [String? path]) {
|
||
return _uploadSingleFile(
|
||
'/api/files/$dirKey/upload',
|
||
file,
|
||
directoryPath: path,
|
||
);
|
||
}
|
||
|
||
Future<Uint8List> downloadFile(String dirKey, String path) async {
|
||
final response = await _client.get(
|
||
_uri('/api/files/$dirKey/download', <String, String>{'path': path}),
|
||
);
|
||
_ensureSuccess(response, expectJson: false);
|
||
return response.bodyBytes;
|
||
}
|
||
|
||
Future<ApiResponse> deleteFile(String dirKey, String path) async {
|
||
final response = await _client.post(
|
||
_uri('/api/files/$dirKey/delete'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(<String, dynamic>{'path': path}),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<ApiResponse> mkdir(String dirKey, String path) async {
|
||
final response = await _client.post(
|
||
_uri('/api/files/$dirKey/mkdir'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(<String, dynamic>{'path': path}),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> getPlugins() async {
|
||
final response = await _client.get(_uri('/api/plugins'));
|
||
_ensureSuccess(response);
|
||
final decoded = _decodeJson(response.body);
|
||
if (decoded is! List) {
|
||
return const <Map<String, dynamic>>[];
|
||
}
|
||
|
||
return decoded
|
||
.whereType<Map>()
|
||
.map((plugin) => Map<String, dynamic>.from(plugin))
|
||
.toList(growable: false);
|
||
}
|
||
|
||
Future<ApiResponse> enablePlugin(String id) {
|
||
return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/enable');
|
||
}
|
||
|
||
Future<ApiResponse> disablePlugin(String id) {
|
||
return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/disable');
|
||
}
|
||
|
||
Future<ApiResponse> installPlugin(String id, [String? version]) async {
|
||
final response = await _client.post(
|
||
_uri('/api/plugins/install'),
|
||
headers: _jsonHeaders,
|
||
body: jsonEncode(<String, dynamic>{
|
||
'id': id,
|
||
if (version != null && version.isNotEmpty) 'version': version,
|
||
}),
|
||
);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<ApiResponse> _uploadSingleFile(
|
||
String endpoint,
|
||
File file, {
|
||
String? directoryPath,
|
||
UploadProgressCallback? onProgress,
|
||
}) async {
|
||
final request = http.MultipartRequest(
|
||
'POST',
|
||
_uri(endpoint, _pathQuery(directoryPath)),
|
||
);
|
||
request.files.add(
|
||
await _createMultipartFile(
|
||
file,
|
||
fieldName: 'file',
|
||
onProgress: onProgress,
|
||
),
|
||
);
|
||
final response = await http.Response.fromStream(await request.send());
|
||
onProgress?.call(1);
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<http.MultipartFile> _createMultipartFile(
|
||
File file, {
|
||
required String fieldName,
|
||
UploadProgressCallback? onProgress,
|
||
}) async {
|
||
final length = await file.length();
|
||
final filename = file.uri.pathSegments.isNotEmpty
|
||
? file.uri.pathSegments.last
|
||
: file.path;
|
||
|
||
if (onProgress == null) {
|
||
return http.MultipartFile.fromPath(fieldName, file.path, filename: filename);
|
||
}
|
||
|
||
var uploaded = 0;
|
||
final stream = http.ByteStream(
|
||
file.openRead().transform(
|
||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||
handleData: (chunk, sink) {
|
||
uploaded += chunk.length;
|
||
if (length > 0) {
|
||
final progress = (uploaded / length).clamp(0.0, 1.0);
|
||
onProgress(progress);
|
||
}
|
||
sink.add(chunk);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
|
||
return http.MultipartFile(
|
||
fieldName,
|
||
stream,
|
||
length,
|
||
filename: filename,
|
||
);
|
||
}
|
||
|
||
Future<ApiResponse> _postCommand(String path) async {
|
||
final response = await _client.post(_uri(path));
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
Future<ApiResponse> _deleteCommand(String path) async {
|
||
final response = await _client.delete(_uri(path));
|
||
_ensureSuccess(response);
|
||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||
}
|
||
|
||
List<WifiNetwork> _decodeWifiNetworks(String body) {
|
||
final decoded = _decodeJson(body);
|
||
if (decoded is! List) {
|
||
return const <WifiNetwork>[];
|
||
}
|
||
|
||
return decoded.map<WifiNetwork>((dynamic item) {
|
||
if (item is Map<String, dynamic>) {
|
||
return WifiNetwork.fromJson(item);
|
||
}
|
||
if (item is Map) {
|
||
return WifiNetwork.fromJson(Map<String, dynamic>.from(item));
|
||
}
|
||
return const WifiNetwork(ssid: '', signal: 0, security: 'Unknown');
|
||
}).where((network) => network.ssid.isNotEmpty).toList(growable: false);
|
||
}
|
||
|
||
dynamic _decodeJson(String body) {
|
||
try {
|
||
return jsonDecode(body);
|
||
} on FormatException catch (error) {
|
||
throw ApiException('JSON 解析失败: ${error.message}');
|
||
}
|
||
}
|
||
|
||
Map<String, dynamic> _decodeMap(String body) {
|
||
final decoded = _decodeJson(body);
|
||
if (decoded is Map<String, dynamic>) {
|
||
return decoded;
|
||
}
|
||
if (decoded is Map) {
|
||
return Map<String, dynamic>.from(decoded);
|
||
}
|
||
throw const ApiException('期望返回 JSON 对象');
|
||
}
|
||
|
||
Map<String, String>? _pathQuery(String? path) {
|
||
if (path == null || path.isEmpty) {
|
||
return null;
|
||
}
|
||
return <String, String>{'path': path};
|
||
}
|
||
|
||
void _ensureSuccess(http.Response response, {bool expectJson = true}) {
|
||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||
return;
|
||
}
|
||
|
||
if (!expectJson) {
|
||
throw ApiException(
|
||
'请求失败 (${response.statusCode}): ${response.body}',
|
||
statusCode: response.statusCode,
|
||
);
|
||
}
|
||
|
||
String message = response.body;
|
||
try {
|
||
final decoded = _decodeJson(response.body);
|
||
if (decoded is Map) {
|
||
message = decoded['message']?.toString() ?? response.body;
|
||
}
|
||
} on ApiException {
|
||
message = response.body;
|
||
}
|
||
|
||
throw ApiException(message, statusCode: response.statusCode);
|
||
}
|
||
|
||
Map<String, String> get _jsonHeaders => const <String, String>{
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
};
|
||
|
||
void dispose() {
|
||
_client.close();
|
||
}
|
||
|
||
static String normalizeBaseUrl(String raw) {
|
||
final trimmed = raw.trim();
|
||
if (trimmed.isEmpty) {
|
||
throw const ApiException('baseUrl 不能为空');
|
||
}
|
||
|
||
final withScheme = trimmed.startsWith('http://') || trimmed.startsWith('https://')
|
||
? trimmed
|
||
: 'http://$trimmed';
|
||
return withScheme.endsWith('/')
|
||
? withScheme.substring(0, withScheme.length - 1)
|
||
: withScheme;
|
||
}
|
||
}
|
||
|
||
class ApiException implements Exception {
|
||
const ApiException(this.message, {this.statusCode});
|
||
|
||
final String message;
|
||
final int? statusCode;
|
||
|
||
@override
|
||
String toString() => message;
|
||
}
|