628 lines
21 KiB
Dart
628 lines
21 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http_parser/http_parser.dart';
|
|
|
|
import '../../app/config/app_config.dart';
|
|
import '../../features/chat/domain/chat_models.dart';
|
|
import '../../features/contracts/domain/contract_models.dart';
|
|
import '../../features/auth/domain/auth_session.dart';
|
|
import '../../features/profile/domain/admin_snapshot.dart';
|
|
import '../../features/profile/domain/shared_ai_status.dart';
|
|
import '../../features/review/domain/review_draft.dart';
|
|
import '../../features/review_history/domain/review_detail.dart';
|
|
import '../../features/review_history/domain/review_history_item.dart';
|
|
import '../../features/review_history/domain/review_result_summary.dart';
|
|
import '../constants/api_endpoints.dart';
|
|
|
|
class ApiClient {
|
|
const ApiClient();
|
|
|
|
String buildUrl(String path) => '${AppConfig.apiBaseUrl}$path';
|
|
|
|
String get reviewTasksUrl => buildUrl(ApiEndpoints.reviewTasks);
|
|
String get loginUrl => buildUrl(ApiEndpoints.login);
|
|
String get currentUserUrl => buildUrl(ApiEndpoints.currentUser);
|
|
String get adminSnapshotUrl => buildUrl(ApiEndpoints.systemAdminSnapshot);
|
|
String get chatStreamUrl => buildUrl(ApiEndpoints.chatStream);
|
|
String get conversationsUrl => buildUrl(ApiEndpoints.conversations);
|
|
String get sharedAiStatusUrl => buildUrl(ApiEndpoints.systemSharedAi);
|
|
String get contractsUrl => buildUrl('/contracts');
|
|
String get contractTemplatesUrl => buildUrl('/contracts/templates');
|
|
|
|
Future<AuthSession> login({required String account, required String password}) async {
|
|
final response = await http.post(
|
|
Uri.parse(loginUrl),
|
|
headers: const {'Content-Type': 'application/json'},
|
|
body: jsonEncode({'account': account, 'password': password}),
|
|
);
|
|
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '登录失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '登录响应缺少 data');
|
|
final user = _extractMap(data['user'], fallback: '登录响应缺少用户信息');
|
|
final accessToken = data['access_token']?.toString() ?? '';
|
|
if (accessToken.isEmpty) {
|
|
throw const ApiException('登录响应缺少 access_token');
|
|
}
|
|
|
|
return AuthSession(
|
|
accessToken: accessToken,
|
|
userId: user['id']?.toString() ?? '',
|
|
tenantId: user['tenant_id']?.toString() ?? '',
|
|
username: user['username']?.toString() ?? account,
|
|
userDisplayName: user['display_name']?.toString() ?? account,
|
|
role: user['role']?.toString() ?? 'unknown',
|
|
status: user['status']?.toString() ?? 'unknown',
|
|
);
|
|
}
|
|
|
|
Future<AuthSession> fetchCurrentUser(String accessToken) async {
|
|
final response = await http.get(
|
|
Uri.parse(currentUserUrl),
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '获取当前用户失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final user = _extractMap(body['data'], fallback: '当前用户响应缺少 data');
|
|
return AuthSession(
|
|
accessToken: accessToken,
|
|
userId: user['id']?.toString() ?? '',
|
|
tenantId: user['tenant_id']?.toString() ?? '',
|
|
username: user['username']?.toString() ?? '',
|
|
userDisplayName: user['display_name']?.toString() ?? user['username']?.toString() ?? '',
|
|
role: user['role']?.toString() ?? 'unknown',
|
|
status: user['status']?.toString() ?? 'unknown',
|
|
);
|
|
}
|
|
|
|
Future<List<ReviewHistoryItem>> fetchReviewHistory(String accessToken) async {
|
|
final response = await http.get(
|
|
Uri.parse(reviewTasksUrl),
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '获取审查记录失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '审查记录响应缺少 data');
|
|
final items = data['items'];
|
|
if (items is! List) {
|
|
throw const ApiException('审查记录响应缺少 items');
|
|
}
|
|
|
|
return items
|
|
.whereType<Map<String, dynamic>>()
|
|
.map(ReviewHistoryItem.fromJson)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
Future<ReviewDetail> fetchReviewDetail({
|
|
required String accessToken,
|
|
required String reviewId,
|
|
}) async {
|
|
final response = await http.get(
|
|
Uri.parse('$reviewTasksUrl/$reviewId'),
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '获取审查详情失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '审查详情响应缺少 data');
|
|
return ReviewDetail.fromJson(data);
|
|
}
|
|
|
|
Future<ReviewResultSummary> fetchReviewResult({
|
|
required String accessToken,
|
|
required String reviewId,
|
|
}) async {
|
|
final response = await http.get(
|
|
Uri.parse('$reviewTasksUrl/$reviewId/result'),
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '获取审查结果失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '审查结果响应缺少 data');
|
|
return ReviewResultSummary.fromJson(data);
|
|
}
|
|
|
|
Future<ReviewReportSummary> fetchReviewReport({
|
|
required String accessToken,
|
|
required String reviewId,
|
|
}) async {
|
|
final response = await http.get(
|
|
Uri.parse('$reviewTasksUrl/$reviewId/report'),
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '获取报告信息失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '报告响应缺少 data');
|
|
return ReviewReportSummary(
|
|
fileName: data['file_name']?.toString() ?? '',
|
|
downloadUrl: resolveServerUrl(data['download_url']?.toString() ?? ''),
|
|
);
|
|
}
|
|
|
|
Future<Uint8List> downloadReportFile({
|
|
required String accessToken,
|
|
required String downloadUrl,
|
|
}) async {
|
|
final uri = Uri.parse(resolveServerUrl(downloadUrl));
|
|
final headers = <String, String>{};
|
|
if (_shouldAttachAuthorization(uri)) {
|
|
headers['Authorization'] = 'Bearer $accessToken';
|
|
}
|
|
|
|
final response = await http.get(uri, headers: headers);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(
|
|
_extractDownloadErrorMessage(response),
|
|
statusCode: response.statusCode,
|
|
);
|
|
}
|
|
if (response.bodyBytes.isEmpty) {
|
|
throw const ApiException('报告文件为空,请稍后重试');
|
|
}
|
|
return response.bodyBytes;
|
|
}
|
|
|
|
Future<void> streamChat({
|
|
required String accessToken,
|
|
required String message,
|
|
String? conversationId,
|
|
Map<String, dynamic>? reviewContext,
|
|
required void Function(ChatStreamEvent event) onEvent,
|
|
}) async {
|
|
final request = http.Request('POST', Uri.parse(chatStreamUrl))
|
|
..headers['Content-Type'] = 'application/json'
|
|
..headers['Authorization'] = 'Bearer $accessToken'
|
|
..body = jsonEncode(<String, dynamic>{
|
|
'message': message,
|
|
if (conversationId != null && conversationId.trim().isNotEmpty) 'conversation_id': conversationId,
|
|
if (reviewContext != null) 'review_context': reviewContext,
|
|
});
|
|
|
|
final streamed = await request.send();
|
|
if (streamed.statusCode != 200) {
|
|
final response = await http.Response.fromStream(streamed);
|
|
throw ApiException(
|
|
_extractDownloadErrorMessage(response),
|
|
statusCode: response.statusCode,
|
|
);
|
|
}
|
|
|
|
var buffer = '';
|
|
await for (final chunk in streamed.stream.transform(utf8.decoder)) {
|
|
buffer += chunk;
|
|
final segments = buffer.split('\n\n');
|
|
buffer = segments.removeLast();
|
|
|
|
for (final segment in segments) {
|
|
final event = _tryParseStreamEvent(segment);
|
|
if (event != null) {
|
|
onEvent(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
final trailingEvent = _tryParseStreamEvent(buffer);
|
|
if (trailingEvent != null) {
|
|
onEvent(trailingEvent);
|
|
}
|
|
}
|
|
|
|
Future<List<ChatConversationListItem>> listConversations(
|
|
String accessToken, {
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
}) async {
|
|
final uri = Uri.parse(conversationsUrl).replace(
|
|
queryParameters: <String, String>{
|
|
'page': '$page',
|
|
'page_size': '$pageSize',
|
|
},
|
|
);
|
|
final response = await http.get(
|
|
uri,
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '获取会话列表失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '会话列表响应缺少 data');
|
|
final items = data['items'];
|
|
if (items is! List) {
|
|
throw const ApiException('会话列表响应缺少 items');
|
|
}
|
|
|
|
return items
|
|
.whereType<Map<String, dynamic>>()
|
|
.map(ChatConversationListItem.fromJson)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
Future<ChatConversationDetail> getConversationDetail(
|
|
String accessToken,
|
|
String conversationId,
|
|
) async {
|
|
final response = await http.get(
|
|
Uri.parse('$conversationsUrl/$conversationId'),
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '获取会话详情失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '会话详情响应缺少 data');
|
|
return ChatConversationDetail.fromJson(data);
|
|
}
|
|
|
|
Future<SharedAiStatus> fetchSharedAiStatus(String accessToken) async {
|
|
final response = await http.get(
|
|
Uri.parse(sharedAiStatusUrl),
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '鑾峰彇 Shared AI 鐘舵€佸け璐?'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: 'Shared AI 鐘舵€佸搷搴旂己灏? data');
|
|
return SharedAiStatus.fromJson(data);
|
|
}
|
|
|
|
Future<AdminSnapshot> fetchAdminSnapshot(String accessToken) async {
|
|
final response = await http.get(
|
|
Uri.parse(adminSnapshotUrl),
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '鑾峰彇绠$悊鎽樿澶辫触'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '绠$悊鎽樿鍝嶅簲缂哄皯 data');
|
|
return AdminSnapshot.fromJson(data);
|
|
}
|
|
|
|
Future<List<ContractTemplateItem>> listContractTemplates(
|
|
String accessToken, {
|
|
String? contractType,
|
|
}) async {
|
|
final uri = Uri.parse(contractTemplatesUrl).replace(
|
|
queryParameters: contractType == null || contractType.trim().isEmpty
|
|
? null
|
|
: <String, String>{'contract_type': contractType},
|
|
);
|
|
final response = await http.get(
|
|
uri,
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '获取合同模板失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = body['data'];
|
|
if (data is! List) {
|
|
throw const ApiException('合同模板响应缺少 data');
|
|
}
|
|
|
|
return data
|
|
.whereType<Map<String, dynamic>>()
|
|
.map(ContractTemplateItem.fromJson)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
Future<ContractPreviewItem> previewContract(
|
|
String accessToken, {
|
|
required String templateId,
|
|
required Map<String, String> variableValues,
|
|
}) async {
|
|
final request = http.MultipartRequest('POST', Uri.parse('$contractsUrl/preview'))
|
|
..headers['Authorization'] = 'Bearer $accessToken'
|
|
..fields['template_id'] = templateId
|
|
..fields['variable_values_json'] = jsonEncode(variableValues);
|
|
|
|
final streamed = await request.send();
|
|
final response = await http.Response.fromStream(streamed);
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '合同预览失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '合同预览响应缺少 data');
|
|
return ContractPreviewItem.fromJson(data);
|
|
}
|
|
|
|
Future<GeneratedContractItem> generateContract(
|
|
String accessToken, {
|
|
required String templateId,
|
|
required Map<String, String> variableValues,
|
|
String exportFormat = 'docx',
|
|
}) async {
|
|
final request = http.MultipartRequest('POST', Uri.parse('$contractsUrl/generate'))
|
|
..headers['Authorization'] = 'Bearer $accessToken'
|
|
..fields['template_id'] = templateId
|
|
..fields['variable_values_json'] = jsonEncode(variableValues)
|
|
..fields['export_format'] = exportFormat;
|
|
|
|
final streamed = await request.send();
|
|
final response = await http.Response.fromStream(streamed);
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '合同生成失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '合同生成响应缺少 data');
|
|
return GeneratedContractItem.fromJson(data);
|
|
}
|
|
|
|
Future<GeneratedContractListResponse> listGeneratedContracts(
|
|
String accessToken, {
|
|
int page = 1,
|
|
int pageSize = 10,
|
|
String? contractType,
|
|
String? keyword,
|
|
}) async {
|
|
final queryParameters = <String, String>{
|
|
'page': '$page',
|
|
'page_size': '$pageSize',
|
|
};
|
|
if (contractType != null && contractType.trim().isNotEmpty) {
|
|
queryParameters['contract_type'] = contractType.trim();
|
|
}
|
|
if (keyword != null && keyword.trim().isNotEmpty) {
|
|
queryParameters['keyword'] = keyword.trim();
|
|
}
|
|
|
|
final response = await http.get(
|
|
Uri.parse(contractsUrl).replace(queryParameters: queryParameters),
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '获取合同历史失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '合同历史响应缺少 data');
|
|
final items = data['items'];
|
|
if (items is! List) {
|
|
throw const ApiException('合同历史响应缺少 items');
|
|
}
|
|
|
|
return GeneratedContractListResponse(
|
|
items: items
|
|
.whereType<Map<String, dynamic>>()
|
|
.map(GeneratedContractListItem.fromJson)
|
|
.toList(growable: false),
|
|
total: (data['total'] as num?)?.toInt() ?? 0,
|
|
page: (data['page'] as num?)?.toInt() ?? page,
|
|
pageSize: (data['page_size'] as num?)?.toInt() ?? pageSize,
|
|
);
|
|
}
|
|
|
|
Future<GeneratedContractItem> getGeneratedContract(
|
|
String accessToken,
|
|
String contractId,
|
|
) async {
|
|
final response = await http.get(
|
|
Uri.parse('$contractsUrl/$contractId'),
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '获取合同详情失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final data = _extractMap(body['data'], fallback: '合同详情响应缺少 data');
|
|
return GeneratedContractItem.fromJson(data);
|
|
}
|
|
|
|
Future<Uint8List> downloadContractFile(
|
|
String accessToken,
|
|
String contractId,
|
|
) async {
|
|
final response = await http.get(
|
|
Uri.parse('$contractsUrl/$contractId/download'),
|
|
headers: {'Authorization': 'Bearer $accessToken'},
|
|
);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(
|
|
_extractDownloadErrorMessage(response),
|
|
statusCode: response.statusCode,
|
|
);
|
|
}
|
|
if (response.bodyBytes.isEmpty) {
|
|
throw const ApiException('合同文件为空,请稍后重试');
|
|
}
|
|
return response.bodyBytes;
|
|
}
|
|
|
|
Future<Map<String, dynamic>> submitReview({
|
|
|
|
required String accessToken,
|
|
required ReviewDraft draft,
|
|
}) async {
|
|
final request = http.MultipartRequest('POST', Uri.parse(reviewTasksUrl))
|
|
..headers['Authorization'] = 'Bearer $accessToken'
|
|
..fields.addAll(draft.toFormFields());
|
|
|
|
final attachment = draft.attachment;
|
|
if (attachment != null) {
|
|
request.files.add(
|
|
http.MultipartFile.fromBytes(
|
|
'file',
|
|
attachment.bytes,
|
|
filename: attachment.name,
|
|
contentType: attachment.mimeType == null || attachment.mimeType!.trim().isEmpty
|
|
? null
|
|
: MediaType.parse(attachment.mimeType!),
|
|
),
|
|
);
|
|
}
|
|
|
|
final streamed = await request.send();
|
|
final response = await http.Response.fromStream(streamed);
|
|
final body = _decodeBody(response.body);
|
|
if (response.statusCode != 200) {
|
|
throw ApiException(_extractMessage(body, fallback: '提交审查失败'), statusCode: response.statusCode);
|
|
}
|
|
|
|
final code = body['code'];
|
|
if (code is num && code != 0) {
|
|
throw ApiException(_extractMessage(body, fallback: '提交审查失败'));
|
|
}
|
|
|
|
return _extractMap(body['data'], fallback: '审查响应缺少 data');
|
|
}
|
|
|
|
Map<String, dynamic> _decodeBody(String body) {
|
|
final decoded = jsonDecode(body);
|
|
if (decoded is Map<String, dynamic>) {
|
|
return decoded;
|
|
}
|
|
throw const ApiException('接口响应格式错误');
|
|
}
|
|
|
|
Map<String, dynamic> _extractMap(Object? value, {required String fallback}) {
|
|
if (value is Map<String, dynamic>) {
|
|
return value;
|
|
}
|
|
throw ApiException(fallback);
|
|
}
|
|
|
|
String _extractMessage(Map<String, dynamic> body, {required String fallback}) {
|
|
final detail = body['detail']?.toString();
|
|
if (detail != null && detail.isNotEmpty) {
|
|
return detail;
|
|
}
|
|
final message = body['message']?.toString();
|
|
if (message != null && message.isNotEmpty) {
|
|
return message;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
static String resolveServerUrl(String path, {String? baseUrl}) {
|
|
final trimmed = path.trim();
|
|
if (trimmed.isEmpty) {
|
|
return trimmed;
|
|
}
|
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
return trimmed;
|
|
}
|
|
|
|
final configuredBaseUrl = (baseUrl ?? AppConfig.apiBaseUrl).trim();
|
|
if (configuredBaseUrl.isEmpty) {
|
|
return trimmed;
|
|
}
|
|
|
|
final baseUri = Uri.parse(configuredBaseUrl);
|
|
final originUri = Uri(
|
|
scheme: baseUri.scheme,
|
|
host: baseUri.host,
|
|
port: baseUri.hasPort ? baseUri.port : null,
|
|
);
|
|
|
|
if (trimmed.startsWith('/')) {
|
|
return originUri.resolve(trimmed).toString();
|
|
}
|
|
if (trimmed.startsWith('api/')) {
|
|
return originUri.resolve('/$trimmed').toString();
|
|
}
|
|
|
|
final normalizedBaseUri = baseUri.replace(
|
|
path: baseUri.path.endsWith('/') ? baseUri.path : '${baseUri.path}/',
|
|
);
|
|
return normalizedBaseUri.resolve(trimmed).toString();
|
|
}
|
|
|
|
ChatStreamEvent? _tryParseStreamEvent(String chunk) {
|
|
final normalized = chunk.trim();
|
|
if (normalized.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
String? dataLine;
|
|
for (final line in normalized.split('\n')) {
|
|
final trimmed = line.trim();
|
|
if (trimmed.startsWith('data: ')) {
|
|
dataLine = trimmed.substring(6);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (dataLine == null || dataLine.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
final decoded = jsonDecode(dataLine);
|
|
if (decoded is Map<String, dynamic>) {
|
|
return ChatStreamEvent.fromJson(decoded);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool _shouldAttachAuthorization(Uri uri) {
|
|
final baseUri = Uri.parse(AppConfig.apiBaseUrl);
|
|
return uri.scheme == baseUri.scheme &&
|
|
uri.host == baseUri.host &&
|
|
uri.port == baseUri.port;
|
|
}
|
|
|
|
String _extractDownloadErrorMessage(http.Response response) {
|
|
final contentType = response.headers['content-type'] ?? '';
|
|
if (contentType.contains('application/json') ||
|
|
contentType.contains('text/json') ||
|
|
contentType.contains('text/plain')) {
|
|
try {
|
|
final body = _decodeBody(response.body);
|
|
return _extractMessage(body, fallback: '下载报告失败');
|
|
} catch (_) {
|
|
if (response.body.trim().isNotEmpty) {
|
|
return response.body.trim();
|
|
}
|
|
}
|
|
}
|
|
return '下载报告失败';
|
|
}
|
|
}
|
|
|
|
class ApiException implements Exception {
|
|
const ApiException(this.message, {this.statusCode});
|
|
|
|
final String message;
|
|
final int? statusCode;
|
|
|
|
bool get isUnauthorized => statusCode == 401 || statusCode == 403;
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|