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,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};
|
||||
}
|
||||
}
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user