76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
359 lines
12 KiB
Dart
359 lines
12 KiB
Dart
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<String, String>? query]) {
|
|
final base = session.apiBase.replaceAll(RegExp(r'/$'), '');
|
|
final p = path.startsWith('/') ? path : '/$path';
|
|
return Uri.parse('$base$p').replace(queryParameters: query);
|
|
}
|
|
|
|
Map<String, String> _headers({bool auth = true}) {
|
|
final h = <String, String>{
|
|
'Content-Type': 'application/json',
|
|
'X-Request-Id': const Uuid().v4(),
|
|
};
|
|
if (auth && session.accessToken.isNotEmpty) {
|
|
h['Authorization'] = 'Bearer ${session.accessToken}';
|
|
}
|
|
return h;
|
|
}
|
|
|
|
Future<dynamic> get(String path, {Map<String, String>? query}) =>
|
|
_send('GET', path, query: query);
|
|
|
|
Future<dynamic> post(String path, [Map<String, dynamic>? body]) =>
|
|
_send('POST', path, body: body);
|
|
|
|
Future<dynamic> patch(String path, [Map<String, dynamic>? body]) =>
|
|
_send('PATCH', path, body: body);
|
|
|
|
final _fileCache = <String, Uint8List>{};
|
|
|
|
Uint8List? peekFile(String id) => _fileCache[id];
|
|
|
|
Future<Uint8List?> 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 = <String, String>{
|
|
'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<Uint8List?> getBytes(String path, {Map<String, String>? query}) async {
|
|
final uri = _uri(path, query);
|
|
final headers = <String, String>{
|
|
'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<Map<String, dynamic>> 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<http.Response> 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<void>.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<String, dynamic>.from(data) : {'id': '$data'};
|
|
}
|
|
|
|
Future<dynamic> _send(
|
|
String method,
|
|
String path, {
|
|
Map<String, String>? query,
|
|
Map<String, dynamic>? 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<void>.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<bool> _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<Map<String, dynamic>> asMaps(dynamic data) {
|
|
final list = extractList(data);
|
|
return list.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList();
|
|
}
|
|
|
|
List<dynamic> 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<String, dynamic> 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<String, dynamic> 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;
|