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
+328
View File
@@ -0,0 +1,328 @@
import 'dart:async';
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/services.dart';
import 'package:geolocator/geolocator.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import '../api/oa_client.dart';
/// 系统截屏、高刷、录音、选图(保留 GIF)。
class DeviceBridge {
DeviceBridge._();
static const _ch = EventChannel('com.fysxkj.oa/device');
static const _m = MethodChannel('com.fysxkj.oa/ota');
static StreamSubscription<dynamic>? _sub;
static final _cbs = <void Function()>{};
static final AudioPlayer _player = AudioPlayer();
static final AudioRecorder _recorder = AudioRecorder();
static String? _recordingPath;
static DateTime? _recordingStarted;
static String? _playingPath;
static OaClient? _api;
static void bindApi(OaClient api) => _api = api;
static bool get _useNativeBridge => Platform.isAndroid;
static Future<void> preferMaxRefresh() async {
if (!_useNativeBridge) return;
try {
await _m.invokeMethod('preferMaxRefresh');
} catch (_) {}
}
static Timer? _clipPoll;
static bool _clipHadImage = false;
static int _lastClipAt = 0;
static void watchScreenshot(void Function() onShot) {
_cbs.add(onShot);
if (Platform.isAndroid || Platform.isWindows) {
_sub ??= _ch.receiveBroadcastStream().listen((event) {
if (event == 'screenshot') {
for (final cb in [..._cbs]) {
cb();
}
}
});
_m.invokeMethod('startScreenshotWatch').catchError((_) {});
} else if (Platform.isLinux) {
_startLinuxScreenshotPoll();
}
}
static void _startLinuxScreenshotPoll() {
_clipPoll ??= Timer.periodic(const Duration(milliseconds: 900), (_) async {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastClipAt < 1500) return;
final has = await _linuxClipboardHasImage();
if (has && !_clipHadImage) {
_clipHadImage = true;
_lastClipAt = now;
for (final cb in [..._cbs]) {
cb();
}
} else if (!has) {
_clipHadImage = false;
}
});
}
static Future<bool> _linuxClipboardHasImage() async {
try {
final x = await Process.run('bash', [
'-c',
'command -v xclip >/dev/null && xclip -selection clipboard -t TARGETS -o 2>/dev/null | grep -qi image && echo 1; '
'command -v wl-paste >/dev/null && wl-paste --list-types 2>/dev/null | grep -qi image && echo 1',
]);
return '${x.stdout}'.contains('1');
} catch (_) {
return false;
}
}
static void unwatchScreenshot(void Function() onShot) {
_cbs.remove(onShot);
if (_cbs.isNotEmpty) return;
if (Platform.isAndroid || Platform.isWindows) {
_m.invokeMethod('stopScreenshotWatch').catchError((_) {});
_sub?.cancel();
_sub = null;
}
_clipPoll?.cancel();
_clipPoll = null;
_clipHadImage = false;
}
static Future<void> startVoice() async {
if (_useNativeBridge) {
await _m.invokeMethod('startVoice');
return;
}
if (!await _recorder.hasPermission()) {
throw Exception('没有麦克风权限');
}
final dir = await getTemporaryDirectory();
_recordingPath = '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
_recordingStarted = DateTime.now();
await _recorder.start(const RecordConfig(encoder: AudioEncoder.aacLc), path: _recordingPath!);
}
static Future<Map<String, dynamic>?> stopVoice() async {
if (_useNativeBridge) {
final raw = await _m.invokeMethod('stopVoice');
if (raw is Map) return Map<String, dynamic>.from(raw);
return null;
}
final path = await _recorder.stop();
_recordingPath = null;
if (path == null || path.isEmpty) return null;
final started = _recordingStarted;
_recordingStarted = null;
final seconds = started == null ? 1 : DateTime.now().difference(started).inSeconds.clamp(1, 60);
return {'path': path, 'seconds': seconds};
}
static Future<void> cancelVoice() async {
if (_useNativeBridge) {
try {
await _m.invokeMethod('cancelVoice');
} catch (_) {}
return;
}
try {
await _recorder.stop();
} catch (_) {}
_recordingPath = null;
_recordingStarted = null;
}
static Future<void> playVoice(String path) async {
if (_useNativeBridge) {
await _m.invokeMethod('playVoice', {'path': path});
return;
}
await _player.stop();
_playingPath = path;
await _player.play(DeviceFileSource(path));
}
static Future<void> stopPlay() async {
if (_useNativeBridge) {
try {
await _m.invokeMethod('stopPlay');
} catch (_) {}
return;
}
await _player.stop();
_playingPath = null;
}
static String? get playingPath => _playingPath;
/// 手机端本地语音转文字(不调 OA 服务端)。
static Future<String?> speechToText({required String audioPath}) async {
if (!_useNativeBridge) return null;
final value = await _m.invokeMethod('speechToText', {'path': audioPath});
final text = '$value'.trim();
return value == null || text.isEmpty || text == 'null' ? null : text;
}
static final AudioPlayer _tonePlayer = AudioPlayer();
static Future<void> startCallTone({bool incoming = false}) async {
if (_useNativeBridge) {
try {
await _m.invokeMethod('startCallTone', {'incoming': incoming});
} catch (_) {}
return;
}
try {
await _tonePlayer.setReleaseMode(ReleaseMode.loop);
await _tonePlayer.play(AssetSource('sounds/ring.wav'));
} catch (_) {}
}
static Future<void> stopCallTone() async {
if (_useNativeBridge) {
try {
await _m.invokeMethod('stopCallTone');
} catch (_) {}
return;
}
try {
await _tonePlayer.stop();
} catch (_) {}
}
static Future<void> cancelCallNotification() async {
if (!_useNativeBridge) return;
try {
await _m.invokeMethod('cancelCallNotification');
} catch (_) {}
}
static Future<void> playMessageSent() async {
if (!_useNativeBridge) return;
try {
await _m.invokeMethod('playMessageSent');
} catch (_) {}
}
static Future<Map<String, double>?> getLocation() async {
if (_useNativeBridge) {
final raw = await _m.invokeMethod('getLocation');
if (raw is Map) {
final lat = (raw['lat'] as num?)?.toDouble();
final lng = (raw['lng'] as num?)?.toDouble();
if (lat != null && lng != null) return {'lat': lat, 'lng': lng};
}
return null;
}
try {
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return null;
var perm = await Geolocator.checkPermission();
if (perm == LocationPermission.denied) {
perm = await Geolocator.requestPermission();
}
if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) {
return null;
}
final pos = await Geolocator.getCurrentPosition(locationSettings: const LocationSettings(accuracy: LocationAccuracy.high));
return {'lat': pos.latitude, 'lng': pos.longitude};
} catch (_) {
return null;
}
}
static Future<Map<String, String>?> pickFile() async {
if (_useNativeBridge) {
final raw = await _m.invokeMethod('pickFile');
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
return null;
}
final picked = await FilePicker.platform.pickFiles(allowMultiple: false);
if (picked == null || picked.files.isEmpty) return null;
final f = picked.files.first;
final path = f.path;
if (path == null || path.isEmpty) return null;
return {'path': path, 'name': f.name, 'mime': f.extension ?? ''};
}
static Future<void> openFile(String path, {String? mime}) async {
if (_useNativeBridge) {
await _m.invokeMethod('openFile', {'path': path, 'mime': mime ?? ''});
return;
}
if (Platform.isLinux) {
await Process.run('xdg-open', [path]);
} else if (Platform.isWindows) {
await Process.start('cmd', ['/c', 'start', '', path], mode: ProcessStartMode.detached);
}
}
static Future<void> saveImage(String path) async {
if (_useNativeBridge) {
await _m.invokeMethod('saveImage', {'path': path});
return;
}
final name = path.split('/').last;
final picked = await FilePicker.platform.saveFile(dialogTitle: '保存图片', fileName: name.isEmpty ? 'image.jpg' : name);
if (picked == null || picked.isEmpty) return;
await File(path).copy(picked);
}
static Future<void> openUrl(String url) async {
if (_useNativeBridge) {
await _m.invokeMethod('openUrl', {'url': url});
return;
}
if (Platform.isLinux) {
await Process.run('xdg-open', [url]);
} else if (Platform.isWindows) {
await Process.start('cmd', ['/c', 'start', '', url], mode: ProcessStartMode.detached);
}
}
static Future<bool> requestCallMedia() async {
if (!_useNativeBridge) return true;
try {
final ok = await _m.invokeMethod('requestCallMedia');
return ok == true;
} catch (_) {
return true;
}
}
static Future<Map<String, String>?> pickMedia() async {
if (_useNativeBridge) {
final raw = await _m.invokeMethod('pickMedia');
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
return null;
}
final picked = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'],
allowMultiple: false,
);
if (picked == null || picked.files.isEmpty) return null;
final f = picked.files.first;
final path = f.path;
if (path == null || path.isEmpty) return null;
final ext = (f.extension ?? 'jpg').toLowerCase();
final mime = ext == 'png'
? 'image/png'
: ext == 'gif'
? 'image/gif'
: ext == 'webp'
? 'image/webp'
: 'image/jpeg';
return {'path': path, 'name': f.name, 'mime': mime};
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:flutter/services.dart';
import '../api/oa_client.dart';
class PushBridge {
PushBridge._();
static const _m = MethodChannel('com.fysxkj.oa/ota');
static const _ev = EventChannel('com.fysxkj.oa/push');
static StreamSubscription<dynamic>? _sub;
static Future<void> requestFirstLoginPermissions() async {
try {
await _m.invokeMethod('requestFirstLoginPermissions');
} catch (_) {
try {
await _m.invokeMethod('requestPushPermission');
} catch (_) {}
}
}
static Future<void> register(OaClient api) async {
await requestFirstLoginPermissions();
for (var i = 0; i < 10; i++) {
try {
final raw = await _m.invokeMethod('getPushToken');
if (raw is Map) {
final regId = '${raw['regId'] ?? ''}';
if (regId.isNotEmpty) {
await api.post('/push/devices', {
'vendor': '${raw['vendor'] ?? 'vivo'}',
'regId': regId,
'brand': '${raw['brand'] ?? ''}',
'model': '${raw['model'] ?? ''}',
});
return;
}
}
} catch (_) {}
await Future<void>.delayed(const Duration(seconds: 2));
}
}
static Future<void> showOnline(String title, String content, {Map<String, String> extras = const {}}) async {
try {
await _m.invokeMethod('showOnlineNotification', {'title': title, 'content': content, 'extras': extras});
} catch (_) {}
}
static Future<Map<String, String>?> consumeLaunch() async {
try {
final raw = await _m.invokeMethod('consumeLaunchPush');
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
} catch (_) {}
return null;
}
static void listen(void Function(Map<String, String>) onClick) {
_sub?.cancel();
_sub = _ev.receiveBroadcastStream().listen((event) {
if (event is Map) {
onClick(event.map((k, v) => MapEntry('$k', '$v')));
}
});
}
static void stop() {
_sub?.cancel();
_sub = null;
}
static Future<Map<String, dynamic>?> oemStatus() async {
try {
final raw = await _m.invokeMethod('oemStatus');
if (raw is Map) return Map<String, dynamic>.from(raw);
} catch (_) {}
return null;
}
static Future<void> openOemKeepAlive() async {
try {
await _m.invokeMethod('openOemKeepAlive');
} catch (_) {}
}
static Future<void> requestBattery() async {
try {
await _m.invokeMethod('requestBattery');
} catch (_) {}
}
static Future<void> markOemPrompted() async {
try {
await _m.invokeMethod('markOemPrompted');
} catch (_) {}
}
}