Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user