import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; import 'package:uuid/uuid.dart'; import '../app_config.dart'; import '../session/session.dart'; class ApiException implements Exception { ApiException(this.message); final String message; @override String toString() => message; } class OaClient { OaClient(this.session); final SessionStore session; bool _refreshing = false; Uri _uri(String path, [Map? query]) { final base = session.apiBase.replaceAll(RegExp(r'/$'), ''); final p = path.startsWith('/') ? path : '/$path'; return Uri.parse('$base$p').replace(queryParameters: query); } Map _headers({bool auth = true}) { final h = { 'Content-Type': 'application/json', 'X-Request-Id': const Uuid().v4(), }; if (auth && session.accessToken.isNotEmpty) { h['Authorization'] = 'Bearer ${session.accessToken}'; } return h; } Future get(String path, {Map? query}) => _send('GET', path, query: query); Future post(String path, [Map? body]) => _send('POST', path, body: body); Future patch(String path, [Map? body]) => _send('PATCH', path, body: body); final _fileCache = {}; Uint8List? peekFile(String id) => _fileCache[id]; Future fileBytes(String id) async { if (id.isEmpty) return null; final hit = _fileCache[id]; if (hit != null) return hit; final uri = _uri('/files/$id'); final headers = { 'X-Request-Id': const Uuid().v4(), if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}', }; try { var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30)); if (res.statusCode == 401 && session.refreshToken.isNotEmpty) { final ok = await _refresh(); if (ok) { headers['Authorization'] = 'Bearer ${session.accessToken}'; res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30)); } } if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null; if (_fileCache.length > 80) _fileCache.remove(_fileCache.keys.first); _fileCache[id] = res.bodyBytes; return res.bodyBytes; } catch (_) { return null; } } Future getBytes(String path, {Map? query}) async { final uri = _uri(path, query); final headers = { 'X-Request-Id': const Uuid().v4(), if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}', }; try { var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20)); if (res.statusCode == 401 && session.refreshToken.isNotEmpty) { final ok = await _refresh(); if (ok) { headers['Authorization'] = 'Bearer ${session.accessToken}'; res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20)); } } if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null; return res.bodyBytes; } catch (_) { return null; } } Future> uploadFile({ required String filePath, required String filename, required String bizType, required String bizId, String? mime, }) async { final uri = _uri('/files'); final type = _mediaType(filename, mime); Future send() async { final req = http.MultipartRequest('POST', uri); req.headers['Authorization'] = 'Bearer ${session.accessToken}'; req.headers['X-Request-Id'] = const Uuid().v4(); req.fields['bizType'] = bizType; req.fields['bizId'] = bizId; req.files.add(await http.MultipartFile.fromPath('file', filePath, filename: filename, contentType: type)); return http.Response.fromStream(await req.send().timeout(const Duration(seconds: 60))); } var res = await send(); if (res.statusCode == 401 && session.refreshToken.isNotEmpty) { final ok = await _refresh(); if (ok) res = await send(); } if ((res.statusCode == 502 || res.statusCode == 503) && session.accessToken.isNotEmpty) { await Future.delayed(const Duration(milliseconds: 700)); res = await send(); } dynamic json; try { json = res.body.isEmpty ? null : jsonDecode(res.body); } catch (_) { throw ApiException(res.statusCode >= 400 ? '上传失败 ${res.statusCode}' : '响应无法解析'); } if (json is Map && json['code'] != null && json['code'] != 0) { throw ApiException('${json['message'] ?? '上传失败'}'); } if (res.statusCode >= 400) { final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode; throw ApiException('$msg'); } final data = json is Map && json.containsKey('data') ? json['data'] : json; return data is Map ? Map.from(data) : {'id': '$data'}; } Future _send( String method, String path, { Map? query, Map? body, bool retry = true, }) async { final uri = _uri(path, query); final headers = _headers( auth: !path.startsWith('/auth/login') && !path.startsWith('/auth/sms-code') && path != '/auth/login-options' && path != '/system/app-release' && !path.startsWith('/legal'), ); http.Response res; try { if (method == 'GET') { res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30)); } else if (method == 'PATCH') { res = await http.patch(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30)); } else { res = await http.post(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30)); } } catch (e) { throw ApiException('网络不可用:$e'); } if ((res.statusCode == 502 || res.statusCode == 503) && retry) { await Future.delayed(const Duration(milliseconds: 700)); return _send(method, path, query: query, body: body, retry: false); } if (res.statusCode == 401 && retry && session.refreshToken.isNotEmpty && !path.contains('/auth/refresh')) { final ok = await _refresh(); if (ok) return _send(method, path, query: query, body: body, retry: false); await session.clear(); throw ApiException('登录已过期,请重新登录'); } dynamic json; try { json = res.body.isEmpty ? null : jsonDecode(res.body); } catch (_) { throw ApiException(res.statusCode >= 400 ? '请求失败 ${res.statusCode}' : '响应无法解析'); } if (json is Map && json['code'] != null && json['code'] != 0) { throw ApiException('${json['message'] ?? '请求失败'}'); } if (res.statusCode >= 400) { final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode; throw ApiException('$msg'); } if (json is Map && json.containsKey('data')) return json['data']; return json; } Future _refresh() async { if (_refreshing) return false; _refreshing = true; try { final uri = _uri('/auth/refresh'); final res = await http.post( uri, headers: _headers(auth: false), body: jsonEncode({ 'refreshToken': session.refreshToken, 'clientKind': 'mobile', }), ); if (res.statusCode >= 400) return false; final json = jsonDecode(res.body); final data = json is Map && json['data'] is Map ? json['data'] as Map : json; final access = '${data['accessToken'] ?? ''}'; final refresh = '${data['refreshToken'] ?? ''}'; if (access.isEmpty) return false; await session.setTokens(access, refresh.isEmpty ? session.refreshToken : refresh); return true; } catch (_) { return false; } finally { _refreshing = false; } } MediaType _mediaType(String filename, String? mime) { var raw = (mime ?? '').split(';').first.trim().toLowerCase(); if (raw == 'image/jpg' || raw == 'image/pjpeg') raw = 'image/jpeg'; if (raw.isEmpty || raw == 'application/octet-stream') { final n = filename.toLowerCase(); if (n.endsWith('.jpg') || n.endsWith('.jpeg')) { raw = 'image/jpeg'; } else if (n.endsWith('.png')) { raw = 'image/png'; } else if (n.endsWith('.gif')) { raw = 'image/gif'; } else if (n.endsWith('.webp')) { raw = 'image/webp'; } else if (n.endsWith('.m4a') || n.endsWith('.mp4')) { raw = 'audio/mp4'; } else if (n.endsWith('.pdf')) { raw = 'application/pdf'; } else { raw = 'application/octet-stream'; } } try { return MediaType.parse(raw); } catch (_) { return MediaType('application', 'octet-stream'); } } } List> asMaps(dynamic data) { final list = extractList(data); return list.whereType().map((e) => Map.from(e)).toList(); } List extractList(dynamic data) { if (data is List) return data; if (data is Map) { for (final k in ['items', 'list', 'rows', 'records', 'data']) { if (data[k] is List) return data[k] as List; } } return const []; } String scalarText(dynamic v) { if (v == null) return ''; if (v is Map) { return '${v['displayName'] ?? v['name'] ?? v['title'] ?? v['departmentName'] ?? ''}'.trim(); } if (v is List) { return v.map(scalarText).where((e) => e.isNotEmpty).join('、'); } final s = '$v'.trim(); if (s.startsWith('{') && (s.contains('id:') || s.contains('tenantId'))) return ''; return s; } String pickTitle(Map row) { for (final k in [ 'title', 'name', 'displayName', 'projectName', 'bidNo', 'contractNo', 'claimNo', 'loanNo', 'projectNo', 'purpose', 'reason', 'label', ]) { final s = scalarText(row[k]); if (s.isNotEmpty) return s; } return row['id']?.toString() ?? '未命名'; } String pickSub(Map row) { for (final k in [ 'departmentName', 'department', 'status', 'title', 'bidNo', 'contractNo', 'claimNo', 'kind', 'mobile', 'lastText', 'employmentStatus', ]) { final s = scalarText(row[k]); if (s.isNotEmpty) return s; } return ''; } String fmtTime(dynamic v) { if (v == null) return ''; final s = '$v'; final t = DateTime.tryParse(s); if (t != null) { final local = t.toLocal(); final date = '${local.year.toString().padLeft(4, '0')}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}'; final time = '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}:${local.second.toString().padLeft(2, '0')}'; return '$date $time'; } if (s.length >= 16) return s.substring(0, 16).replaceFirst('T', ' '); return s; } String shortTime(dynamic v) { if (v == null) return ''; final t = DateTime.tryParse('$v'); if (t == null) return fmtTime(v); final now = DateTime.now(); final local = t.toLocal(); final today = DateTime(now.year, now.month, now.day); final day = DateTime(local.year, local.month, local.day); final hh = local.hour.toString().padLeft(2, '0'); final mm = local.minute.toString().padLeft(2, '0'); if (day == today) return '$hh:$mm'; if (day == today.subtract(const Duration(days: 1))) return '昨天'; const week = ['一', '二', '三', '四', '五', '六', '日']; if (today.difference(day).inDays < 7) return '周${week[local.weekday - 1]}'; return '${local.month}/${local.day}'; } const defaultApiHint = AppConfig.defaultApiBase;