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:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// 聊天消息的本地副本。云端只保留 7 天,客户端仍可查看本机历史消息。
class ChatLocalStore {
ChatLocalStore._();
static Future<List<Map<String, dynamic>>> load(String conversationId) async {
if (conversationId.isEmpty) return [];
final p = await SharedPreferences.getInstance();
final raw = p.getString('im.local.$conversationId');
if (raw == null || raw.isEmpty) return [];
try {
final list = jsonDecode(raw);
if (list is! List) return [];
return list.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList();
} catch (_) {
return [];
}
}
static Future<void> save(String conversationId, List<Map<String, dynamic>> items) async {
if (conversationId.isEmpty) return;
final p = await SharedPreferences.getInstance();
final durable = items.where((e) => e['_pending'] != true).map((e) {
final copy = Map<String, dynamic>.from(e)..remove('_pending');
return copy;
}).toList();
await p.setString('im.local.$conversationId', jsonEncode(durable));
}
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:shared_preferences/shared_preferences.dart';
/// 会话级本地偏好:免打扰、置顶、背景、本地清空记录。
class ChatPrefs {
ChatPrefs._();
static SharedPreferences? _p;
static Future<void> ensure() async {
_p ??= await SharedPreferences.getInstance();
// 旧版本曾在滑动过程中误写 hidden,导致会话读完后看似“自动删除”。
// 升级时清理一次旧标记,之后只有确认“不显示/删除”才会再次隐藏。
if (_p!.getBool('im.hidden.migration.v3') != true) {
for (final key
in _p!.getKeys().where((k) => k.startsWith('im.hidden.'))) {
await _p!.remove(key);
}
await _p!.setBool('im.hidden.migration.v3', true);
}
}
static bool muted(String id) =>
id.isEmpty ? false : (_p?.getBool('im.mute.$id') ?? false);
static bool pinned(String id) =>
id.isEmpty ? false : (_p?.getBool('im.pin.$id') ?? false);
static bool hidden(String id) =>
id.isEmpty ? false : (_p?.getBool('im.hidden.$id') ?? false);
static int? bgColor(String id) => id.isEmpty ? null : _p?.getInt('im.bg.$id');
static DateTime? clearedAt(String id) {
if (id.isEmpty) return null;
final ms = _p?.getInt('im.cleared.$id');
if (ms == null || ms <= 0) return null;
return DateTime.fromMillisecondsSinceEpoch(ms);
}
static Future<void> setMuted(String id, bool v) async {
await ensure();
await _p!.setBool('im.mute.$id', v);
}
static Future<void> setPinned(String id, bool v) async {
await ensure();
await _p!.setBool('im.pin.$id', v);
}
static Future<void> setHidden(String id, bool v) async {
await ensure();
await _p!.setBool('im.hidden.$id', v);
}
static Future<void> setBg(String id, int color) async {
await ensure();
await _p!.setInt('im.bg.$id', color);
}
static Future<void> clearHistory(String id) async {
await ensure();
await _p!.setInt('im.cleared.$id', DateTime.now().millisecondsSinceEpoch);
}
static int fakeUnread(String id) =>
id.isEmpty ? 0 : (_p?.getInt('im.fakeUnread.$id') ?? 0);
static Future<void> setFakeUnread(String id, int n) async {
await ensure();
if (n <= 0) {
await _p!.remove('im.fakeUnread.$id');
} else {
await _p!.setInt('im.fakeUnread.$id', n);
}
}
/// 语音转文字结果,按消息 id 或 fileId 缓存。
static String? voiceText(String key) {
if (key.isEmpty) return null;
final v = _p?.getString('im.voiceText.$key');
if (v == null || v.isEmpty) return null;
return v;
}
static Future<void> setVoiceText(String key, String text) async {
if (key.isEmpty) return;
await ensure();
final t = text.trim();
if (t.isEmpty) {
await _p!.remove('im.voiceText.$key');
} else {
await _p!.setString('im.voiceText.$key', t);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/services.dart';
/// 与官方 MobileIMSDK 对齐的生命周期:登录前 init,退出 release。
/// Android 走 MethodChannelWindows/Linux 无原生插件时回落到 Dart TCP。
class ImBridge {
static const _ch = MethodChannel('com.fysxkj.oa/im');
bool nativeReady = false;
String engine = 'dart-tcp';
Future<void> init() async {
try {
final r = await _ch.invokeMethod<Map>('init');
nativeReady = r?['ok'] == true || r?['initialized'] == true;
engine = '${r?['engine'] ?? 'dart-tcp'}';
} on MissingPluginException {
nativeReady = false;
engine = 'dart-tcp';
} catch (_) {
nativeReady = false;
engine = 'dart-tcp';
}
}
Future<void> release() async {
try {
await _ch.invokeMethod('release');
} catch (_) {}
nativeReady = false;
}
Future<String> status() async {
try {
final r = await _ch.invokeMethod<String>('engine');
if (r != null && r.isNotEmpty) engine = r;
} catch (_) {}
return engine;
}
}
+42
View File
@@ -0,0 +1,42 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/widgets.dart';
import '../session/session.dart';
/// 监听网络切换与应用恢复,触发 IM 极速重连。
class ImLifecycle with WidgetsBindingObserver {
ImLifecycle(this.session);
final SessionStore session;
StreamSubscription<List<ConnectivityResult>>? _netSub;
List<ConnectivityResult> _lastNet = const [];
void start() {
WidgetsBinding.instance.addObserver(this);
_netSub = Connectivity().onConnectivityChanged.listen(_onNet);
Connectivity().checkConnectivity().then((r) => _lastNet = r);
}
void stop() {
WidgetsBinding.instance.removeObserver(this);
_netSub?.cancel();
_netSub = null;
}
void _onNet(List<ConnectivityResult> next) {
final wasOffline = _lastNet.every((e) => e == ConnectivityResult.none);
final nowOnline = next.any((e) => e != ConnectivityResult.none);
_lastNet = next;
if (wasOffline && nowOnline && session.signedIn) {
unawaited(session.connectIm(force: true));
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && session.signedIn) {
unawaited(session.connectIm(force: true));
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:shared_preferences/shared_preferences.dart';
/// 本地记录各会话已同步到的最大 seq,断线重连后用于 gap fill。
class ImSeqStore {
ImSeqStore._();
static SharedPreferences? _p;
static Future<void> ensure() async {
_p ??= await SharedPreferences.getInstance();
}
static Future<int> lastSeq(String conversationId) async {
if (conversationId.isEmpty) return 0;
await ensure();
return _p?.getInt('im.seq.$conversationId') ?? 0;
}
static Future<void> setLastSeq(String conversationId, int seq) async {
if (conversationId.isEmpty || seq <= 0) return;
await ensure();
final prev = _p?.getInt('im.seq.$conversationId') ?? 0;
if (seq > prev) await _p!.setInt('im.seq.$conversationId', seq);
}
static Future<void> applyItems(String conversationId, Iterable<Map<String, dynamic>> items) async {
var max = 0;
for (final e in items) {
final s = (e['seq'] as num?)?.toInt() ?? 0;
if (s > max) max = s;
}
if (max > 0) await setLastSeq(conversationId, max);
}
}
+81
View File
@@ -0,0 +1,81 @@
/// 姓名首字母。按百家姓,避免大量同事被丢进「#」。
const _surnames = {
'': 'Z', '': 'Q', '': 'S', '': 'L', '': 'Z', '': 'W', '': 'Z', '': 'W',
'': 'F', '': 'C', '': 'C', '': 'W', '': 'J', '': 'S', '': 'H', '': 'Y',
'': 'Z', '': 'Q', '': 'Y', '': 'X', '': 'H', '': 'L', '': 'S', '': 'Z',
'': 'K', '': 'C', '': 'Y', '': 'H', '': 'J', '': 'W', '': 'T', '': 'J',
'': 'Q', '': 'X', '': 'Z', '': 'Y', '': 'B', '': 'S', '': 'D', '': 'Z',
'': 'Y', '': 'S', '': 'P', '': 'G', '': 'X', '': 'F', '': 'P', '': 'L',
'': 'L', '': 'W', '': 'C', '': 'M', '': 'M', '': 'F', '': 'H', '': 'F',
'': 'Y', '': 'R', '': 'Y', '': 'L', '': 'F', '': 'B', '': 'S', '': 'T',
'': 'F', '': 'L', '': 'C', '': 'X', '': 'L', '': 'H', '': 'N', '': 'T',
'': 'T', '': 'Y', '': 'L', '': 'B', '': 'H', '': 'W', '': 'A', '': 'C',
'': 'Y', '': 'Y', '': 'S', '': 'F', '': 'P', '': 'B', '': 'Q', '': 'K',
'': 'W', '': 'Y', '': 'Y', '': 'B', '': 'G', '': 'M', '': 'P', '': 'H',
'': 'H', '': 'M', '': 'X', '': 'Y', '': 'Y', '': 'S', '': 'Z', '': 'W',
'': 'Q', '': 'M', '': 'Y', '': 'D', '': 'M', '': 'B', '': 'M', '': 'Z',
'': 'J', '': 'F', '': 'C', '': 'D', '': 'T', '': 'S', '': 'M', '': 'P',
'': 'X', '': 'J', '': 'S', '': 'Q', '': 'X', '': 'Z', '': 'D', '': 'L',
'': 'D', '': 'R', '': 'L', '': 'M', '': 'X', '': 'J', '': 'M', '': 'Q',
'': 'J', '': 'L', '': 'L', '': 'W', '': 'J', '': 'T', '': 'Y', '': 'G',
'': 'M', '': 'S', '': 'L', '': 'D', '': 'Z', '': 'X', '': 'Q', '': 'L',
'': 'G', '': 'X', '': 'C', '': 'T', '': 'F', '': 'H', '': 'L', '': 'H',
'': 'Y', '': 'W', '': 'Z', '': 'K', '': 'Z', '': 'G', '': 'L', '': 'M',
'': 'J', '': 'F', '': 'Q', '': 'M', '': 'G', '': 'X', '': 'Y', '': 'Z',
'': 'D', '': 'X', '': 'B', '': 'D', '': 'Y', '': 'S', '': 'H', '': 'H',
'': 'B', '': 'Z', '': 'Z', '': 'S', '': 'C', '': 'J', '': 'N', '': 'G',
'': 'C', '': 'J', '': 'X', '': 'H', '': 'P', '': 'L', '': 'R', '': 'W',
'': 'X', '': 'Y', '': 'Y', '': 'H', '': 'Z', '': 'Q', '': 'J', '': 'F',
'': 'R', '羿': 'Y', '': 'C', '': 'J', '': 'J', '': 'B', '': 'M', '': 'S',
'': 'J', '': 'D', '': 'F', '': 'W', '': 'W', '': 'J', '': 'B', '': 'G',
'': 'M', '': 'K', '': 'S', '': 'G', '': 'C', '': 'H', '': 'M', '': 'P',
'': 'Q', '': 'X', '': 'B', '': 'Y', '': 'Q', '': 'Z', '': 'Y', '': 'G',
'': 'N', '': 'Q', '': 'L', '': 'B', '': 'G', '': 'T', '': 'L', '': 'R',
'': 'Z', '': 'W', '': 'F', '': 'L', '': 'J', '': 'Z', '': 'S', '': 'L',
'': 'Y', '': 'X', '': 'S', '': 'S', '': 'G', '': 'L', '': 'J', '': 'B',
'': 'Y', '宿': 'S', '': 'B', '怀': 'H', '': 'P', '': 'T', '': 'C', '': 'E',
'': 'S', '': 'X', '': 'J', '': 'L', '': 'Z', '': 'L', '': 'T', '': 'M',
'': 'C', '': 'Q', '': 'Y', '': 'X', '': 'N', '': 'C', '': 'S', '': 'W',
'': 'S', '': 'D', '': 'Z', '': 'T', '': 'G', '': 'L', '': 'P', '': 'J',
'': 'S', '': 'F', '': 'D', '': 'R', '': 'Z', '': 'L', '': 'Y', '': 'X',
'': 'Q', '': 'S', '': 'G', '': 'P', '': 'N', '寿': 'S', '': 'T', '': 'B',
'': 'H', '': 'Y', '': 'J', '': 'J', '': 'P', '': 'S', '': 'N', '': 'W',
'': 'B', '': 'Z', '': 'Y', '': 'C', '': 'Q', '': 'Y', '': 'C', '': 'M',
'': 'L', '': 'R', '': 'X', '': 'H', '': 'A', '': 'Y', '': 'R', '': 'X',
'': 'G', '': 'Y', '': 'S', '': 'G', '': 'L', '': 'Y', '': 'Z', '': 'J',
'': 'J', '': 'H', '': 'B', '': 'D', '': 'G', '': 'M', '': 'H', '': 'K',
'': 'G', '': 'W', '': 'K', '广': 'G', '': 'L', '': 'Q', '': 'D', '': 'O',
'': 'S', '': 'W', '': 'L', '': 'W', '': 'Y', '': 'K', '': 'L', '': 'S',
'': 'G', '': 'S', '': 'N', '': 'C', '': 'G', '': 'A', '': 'R', '': 'L',
'': 'Z', '': 'X', '': 'K', '': 'N', '': 'J', '': 'R', '': 'K', '': 'Z',
'': 'W', '': 'S', '': 'N', '': 'Y', '': 'J', '': 'X', '': 'F', '': 'C',
'': 'G', '': 'K', '': 'X', '': 'Z', '': 'H', '': 'J', '': 'H', '': 'Y',
'': 'Z', '': 'Q', '': 'L', '': 'G', '': 'Y', '': 'H', '': 'G', '万俟': 'M',
'司马': 'S', '上官': 'S', '欧阳': 'O', '夏侯': 'X', '诸葛': 'Z', '闻人': 'W', '东方': 'D',
'赫连': 'H', '皇甫': 'H', '尉迟': 'Y', '公羊': 'G', '澹台': 'T', '公冶': 'G', '宗政': 'Z',
'濮阳': 'P', '淳于': 'C', '单于': 'C', '太叔': 'T', '申屠': 'S', '公孙': 'G', '仲孙': 'Z',
'轩辕': 'X', '令狐': 'L', '钟离': 'Z', '宇文': 'Y', '长孙': 'Z', '慕容': 'M', '鲜于': 'X',
'闾丘': 'L', '司徒': 'S', '司空': 'S', '丌官': 'Q', '司寇': 'S', '': 'Z', '': 'D',
'子车': 'Z', '颛孙': 'Z', '端木': 'D', '巫马': 'W', '公西': 'G', '漆雕': 'Q', '乐正': 'Y',
'壤驷': 'R', '公良': 'G', '拓跋': 'T', '夹谷': 'J', '宰父': 'Z', '谷梁': 'G', '': 'J',
'': 'C', '': 'Y', '': 'F', '': 'R', '': 'Y', '': 'T', '': 'Q', '段干': 'D',
'百里': 'B', '东郭': 'D', '南门': 'N', '呼延': 'H', '': 'G', '': 'H', '羊舌': 'Y',
'微生': 'W', '': 'Y', '': 'S', '': 'G', '': 'K', '': 'K', '': 'Y',
'': 'Q', '梁丘': 'L', '左丘': 'Z', '东门': 'D', '西门': 'X', '': 'S', '': 'M',
'': 'S', '': 'N', '': 'B', '': 'S', '南宫': 'N', '': 'M', '': 'H', '': 'Q',
'': 'D', '': 'N', '': 'A', '': 'Y', '': 'T', '': 'Y', '': 'F', '西': 'X',
'': 'N', '': 'B',
};
String letterOf(String name) {
final n = name.trim();
if (n.isEmpty) return '#';
if (n.length >= 2) {
final two = _surnames[n.substring(0, 2)];
if (two != null) return two;
}
final c = n[0];
if (RegExp(r'[A-Za-z]').hasMatch(c)) return c.toUpperCase();
if (RegExp(r'[0-9]').hasMatch(c)) return '#';
return _surnames[c] ?? '#';
}
+73
View File
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'dart:typed_data';
/// MobileIMSDK 官方 TCP 帧:4 字节大端长度 + UTF-8 JSON Protocal。
class ClientType {
static const login = 0;
static const keepAlive = 1;
static const commonData = 2;
static const logout = 3;
static const received = 4;
static const echo = 5;
}
class ServerType {
static const login = 50;
static const keepAlive = 51;
static const error = 52;
static const echo = 53;
static const kickout = 54;
}
class Protocal {
Protocal({
required this.type,
this.dataContent = '',
this.from = '0',
this.to = '0',
this.fp,
this.qos = false,
this.typeu = -1,
this.sm = -1,
});
int type;
String dataContent;
String from;
String to;
String? fp;
bool qos;
int typeu;
int sm;
Map<String, dynamic> toJson() => {
'bridge': false,
'type': type,
'dataContent': dataContent,
'from': from,
'to': to,
'fp': fp,
'QoS': qos,
'typeu': typeu,
'sm': sm,
};
factory Protocal.fromJson(Map<String, dynamic> j) => Protocal(
type: (j['type'] as num?)?.toInt() ?? 0,
dataContent: '${j['dataContent'] ?? ''}',
from: '${j['from'] ?? '0'}',
to: '${j['to'] ?? '0'}',
fp: j['fp']?.toString(),
qos: j['QoS'] == true || j['qos'] == true,
typeu: (j['typeu'] as num?)?.toInt() ?? -1,
sm: (j['sm'] as num?)?.toInt() ?? -1,
);
}
Uint8List encodeFrame(Protocal p) {
final body = utf8.encode(jsonEncode(p.toJson()));
final header = ByteData(4)..setUint32(0, body.length);
return Uint8List.fromList([...header.buffer.asUint8List(), ...body]);
}
const maxBody = 6 * 1024;
+32
View File
@@ -0,0 +1,32 @@
import 'package:shared_preferences/shared_preferences.dart';
/// 自定义表情:服务端 fileId 列表。GIF / 表情面板发出的都走这里。
class StickerStore {
StickerStore._();
static const _key = 'im.stickers';
static SharedPreferences? _p;
static List<String> _ids = [];
static List<String> get ids => List.unmodifiable(_ids);
static Future<void> ensure() async {
_p ??= await SharedPreferences.getInstance();
_ids = _p!.getStringList(_key) ?? [];
}
static bool has(String fileId) => fileId.isNotEmpty && _ids.contains(fileId);
static Future<void> add(String fileId) async {
if (fileId.isEmpty) return;
await ensure();
if (_ids.contains(fileId)) return;
_ids = [fileId, ..._ids];
await _p!.setStringList(_key, _ids);
}
static Future<void> remove(String fileId) async {
await ensure();
_ids = _ids.where((e) => e != fileId).toList();
await _p!.setStringList(_key, _ids);
}
}
+300
View File
@@ -0,0 +1,300 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import 'protocol.dart';
import '../device/device_bridge.dart';
import '../device/push_bridge.dart';
class ImIncoming {
ImIncoming({
required this.from,
required this.text,
this.kind = 'chat',
this.fp,
this.seq = 0,
this.raw = const {},
});
final String from;
final String text;
final String kind;
final String? fp;
final int seq;
final Map<String, dynamic> raw;
}
class _Pending {
_Pending(this.p);
final Protocal p;
int tries = 0;
}
/// 官方 TCP ClientCoreSDK 等价实现:登录、心跳、通用数据、QoS 应答与重发。
class ImTcpClient extends ChangeNotifier {
Socket? _socket;
Uint8List _buf = Uint8List(0);
Timer? _hb;
Timer? _qos;
Timer? _reconnect;
bool connected = false;
bool loggedIn = false;
String lastError = '';
String userId = '';
int firstLoginTime = 0;
final Map<String, _Pending> _pending = {};
final List<ImIncoming> inbox = [];
final _uuid = const Uuid();
String _host = '';
int _port = 0;
String _token = '';
bool _want = false;
int _tries = 0;
DateTime _lastHbAck = DateTime.now();
VoidCallback? onLoggedIn;
Future<void> connect({
required String host,
required int port,
required String userId,
required String token,
}) async {
_host = host;
_port = port;
_token = token;
this.userId = userId;
_want = true;
_tries = 0;
await _open();
}
Future<void> _open() async {
_tearDownSocket();
lastError = '';
try {
_socket = await Socket.connect(_host, _port,
timeout: const Duration(seconds: 8));
connected = true;
_tries = 0;
notifyListeners();
_socket!.listen(_onData, onDone: _onDrop, onError: (_) => _onDrop());
firstLoginTime = DateTime.now().millisecondsSinceEpoch;
_send(Protocal(
type: ClientType.login,
from: userId,
dataContent: jsonEncode({
'loginUserId': userId,
'loginToken': _token,
'clientKind': 'mobile',
'extra': '',
'firstLoginTime': firstLoginTime,
}),
));
_hb = Timer.periodic(const Duration(seconds: 5), (_) {
if (loggedIn) {
if (DateTime.now().difference(_lastHbAck).inSeconds > 25) {
_onDrop();
return;
}
_send(Protocal(type: ClientType.keepAlive, from: userId));
}
});
_qos = Timer.periodic(const Duration(seconds: 4), (_) => _retryQos());
} catch (e) {
lastError = 'IM 连接失败:$e';
connected = false;
loggedIn = false;
notifyListeners();
_scheduleReconnect();
}
}
void _onDrop() {
final was = connected || loggedIn;
_tearDownSocket();
if (was) notifyListeners();
if (_want) _scheduleReconnect();
}
void _scheduleReconnect() {
if (!_want) return;
_reconnect?.cancel();
_tries = (_tries + 1).clamp(1, 12);
_reconnect = Timer(Duration(seconds: _tries < 4 ? 2 : 5), () {
if (_want && !loggedIn) _open();
});
}
void disconnect() {
_want = false;
_reconnect?.cancel();
_reconnect = null;
_tearDownSocket();
notifyListeners();
}
void _tearDownSocket() {
_hb?.cancel();
_qos?.cancel();
_hb = null;
_qos = null;
try {
_socket?.destroy();
} catch (_) {}
_socket = null;
_buf = Uint8List(0);
connected = false;
loggedIn = false;
}
void bump() {
notifyListeners();
}
Future<String?> sendChat(String toUserId, String text) async {
if (!loggedIn) return null;
final fp = _uuid.v4();
final p = Protocal(
type: ClientType.commonData,
from: userId,
to: toUserId,
dataContent:
jsonEncode({'kind': 'chat', 'text': text, 'fromUserId': userId}),
qos: true,
fp: fp,
typeu: 0,
sm: DateTime.now().millisecondsSinceEpoch,
);
_pending[fp] = _Pending(p);
_send(p);
return fp;
}
void _retryQos() {
for (final e in [..._pending.entries]) {
e.value.tries++;
if (e.value.tries > 3) {
_pending.remove(e.key);
continue;
}
_send(e.value.p);
}
}
void _send(Protocal p) {
final s = _socket;
if (s == null) return;
try {
s.add(encodeFrame(p));
} catch (_) {}
}
void _onData(List<int> chunk) {
_buf = Uint8List.fromList([..._buf, ...chunk]);
while (_buf.length >= 4) {
final len = ByteData.sublistView(_buf).getUint32(0);
if (len <= 0 || len > maxBody) {
disconnect();
return;
}
if (_buf.length < 4 + len) break;
final body = utf8.decode(_buf.sublist(4, 4 + len));
_buf = Uint8List.fromList(_buf.sublist(4 + len));
try {
_handle(Protocal.fromJson(jsonDecode(body) as Map<String, dynamic>));
} catch (_) {}
}
}
void _handle(Protocal p) {
switch (p.type) {
case ServerType.login:
try {
final info = jsonDecode(p.dataContent.isEmpty ? '{}' : p.dataContent);
loggedIn = info is Map && info['code'] == 0;
if (loggedIn && info['firstLoginTime'] != null) {
firstLoginTime = (info['firstLoginTime'] as num).toInt();
}
if (!loggedIn) lastError = 'IM 登录被拒绝';
} catch (_) {
loggedIn = false;
}
if (loggedIn) {
_lastHbAck = DateTime.now();
onLoggedIn?.call();
}
notifyListeners();
break;
case ServerType.keepAlive:
case ServerType.echo:
_lastHbAck = DateTime.now();
break;
case ServerType.kickout:
_tearDownSocket();
notifyListeners();
if (_want) _scheduleReconnect();
break;
case ClientType.received:
_pending.remove(p.dataContent);
break;
case ClientType.commonData:
if (p.qos && p.fp != null) {
_send(Protocal(
type: ClientType.received,
from: userId,
to: p.from,
dataContent: p.fp!));
}
String text = p.dataContent;
var kind = 'chat';
Map<String, dynamic> raw = {};
try {
raw = Map<String, dynamic>.from(jsonDecode(p.dataContent) as Map);
text = '${raw['text'] ?? raw['title'] ?? raw['body'] ?? text}';
kind = '${raw['kind'] ?? 'chat'}';
} catch (_) {}
final seq = (raw['seq'] as num?)?.toInt() ??
(p.sm > 0 ? p.sm : 0);
final fp = '${raw['fingerprint'] ?? p.fp ?? ''}';
if (fp.isNotEmpty) {
final dup = inbox.any((e) => e.fp == fp);
if (dup) break;
}
inbox.insert(
0,
ImIncoming(
from: p.from,
text: text,
kind: kind,
fp: fp.isEmpty ? p.fp : fp,
seq: seq,
raw: raw));
final terminalCall = kind == 'call' &&
const {'end', 'reject', 'timeout'}
.contains('${raw['action'] ?? ''}');
if (terminalCall) {
unawaited(DeviceBridge.cancelCallNotification());
} else if (p.from != userId && kind != 'receipt' && kind != 'signal') {
unawaited(PushBridge.showOnline(
'${raw['fromName'] ?? raw['title'] ?? '风影办公'}',
text,
extras: {
if (raw['conversationId'] != null)
'conversationId': '${raw['conversationId']}',
if (raw['kind'] != null) 'kind': '$kind',
if (raw['callId'] != null) 'callId': '${raw['callId']}',
if (raw['callKind'] != null) 'callKind': '${raw['callKind']}',
if (raw['fromName'] != null) 'fromName': '${raw['fromName']}',
if (raw['fromUserId'] != null) 'peerId': '${raw['fromUserId']}',
if (raw['action'] != null) 'action': '${raw['action']}',
},
));
}
if (inbox.length > 200) inbox.removeLast();
notifyListeners();
break;
}
}
}