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,251 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../app_runtime.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
class AppRelease {
|
||||
AppRelease({
|
||||
required this.version,
|
||||
required this.build,
|
||||
required this.url,
|
||||
this.changelog = '',
|
||||
this.force = false,
|
||||
this.windowsUrl = '',
|
||||
this.linuxUrl = '',
|
||||
this.linuxDebUrl = '',
|
||||
});
|
||||
final String version;
|
||||
final int build;
|
||||
final String url;
|
||||
final String changelog;
|
||||
final bool force;
|
||||
final String windowsUrl;
|
||||
final String linuxUrl;
|
||||
final String linuxDebUrl;
|
||||
|
||||
bool get newer => build > AppRuntime.build;
|
||||
|
||||
String get desktopUrl {
|
||||
if (Platform.isWindows) return windowsUrl;
|
||||
if (Platform.isLinux) return linuxDebUrl.isNotEmpty ? linuxDebUrl : linuxUrl;
|
||||
return '';
|
||||
}
|
||||
|
||||
factory AppRelease.fromJson(Map<String, dynamic> j) {
|
||||
return AppRelease(
|
||||
version: '${j['version'] ?? ''}',
|
||||
build: (j['build'] as num?)?.toInt() ?? 0,
|
||||
url: '${j['url'] ?? j['apkUrl'] ?? ''}',
|
||||
changelog: '${j['changelog'] ?? ''}',
|
||||
force: j['force'] == true,
|
||||
windowsUrl: '${j['windowsUrl'] ?? ''}',
|
||||
linuxUrl: '${j['linuxUrl'] ?? ''}',
|
||||
linuxDebUrl: '${j['linuxDebUrl'] ?? ''}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OtaUpdater {
|
||||
OtaUpdater(this.api);
|
||||
final OaClient api;
|
||||
static const _ch = MethodChannel('com.fysxkj.oa/ota');
|
||||
|
||||
Future<AppRelease?> fetch() async {
|
||||
try {
|
||||
final data = await api.get('/system/app-release');
|
||||
if (data is Map) return AppRelease.fromJson(Map<String, dynamic>.from(data));
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> installApk(String path) async {
|
||||
await _ch.invokeMethod('installApk', {'path': path});
|
||||
}
|
||||
|
||||
Future<File> download(String url, void Function(double p) onProgress, {String filename = 'fengying-oa-update'}) async {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final ext = url.contains('.exe')
|
||||
? '.exe'
|
||||
: url.contains('.deb')
|
||||
? '.deb'
|
||||
: url.contains('.tar.gz')
|
||||
? '.tar.gz'
|
||||
: url.contains('.apk')
|
||||
? '.apk'
|
||||
: '';
|
||||
final file = File('${dir.path}/$filename$ext');
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.getUrl(Uri.parse(url));
|
||||
final res = await req.close();
|
||||
if (res.statusCode >= 400) {
|
||||
throw ApiException('下载失败 ${res.statusCode}');
|
||||
}
|
||||
final total = res.contentLength;
|
||||
var rec = 0;
|
||||
final sink = file.openWrite();
|
||||
await for (final chunk in res) {
|
||||
rec += chunk.length;
|
||||
sink.add(chunk);
|
||||
if (total > 0) onProgress(rec / total);
|
||||
}
|
||||
await sink.close();
|
||||
return file;
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> prompt(BuildContext context, AppRelease rel, {bool manual = false}) async {
|
||||
if (!rel.newer && !manual) return;
|
||||
if (!rel.newer && manual) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已是最新版本')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: !rel.force,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('发现新版本 ${rel.version}'),
|
||||
content: Text(rel.changelog.isEmpty ? '请升级后继续使用。' : rel.changelog),
|
||||
actions: [
|
||||
if (!rel.force) TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('稍后')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('立即升级')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (go == true && context.mounted) await start(context, rel);
|
||||
}
|
||||
|
||||
Future<void> start(BuildContext context, AppRelease rel) async {
|
||||
final desktop = rel.desktopUrl;
|
||||
if (Platform.isWindows || Platform.isLinux) {
|
||||
if (desktop.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无桌面端升级包地址')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final progress = ValueNotifier<double>(0);
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('正在下载更新'),
|
||||
content: OtaProgress(progress: progress),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final file = await download(desktop, (p) => progress.value = p, filename: 'fengying-oa-setup');
|
||||
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
|
||||
if (Platform.isWindows) {
|
||||
await Process.start(file.path, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'], mode: ProcessStartMode.detached);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('安装程序已启动,请按提示完成升级后重启应用')));
|
||||
}
|
||||
} else if (file.path.endsWith('.deb')) {
|
||||
final r = await Process.run('pkexec', ['env', 'DEBIAN_FRONTEND=noninteractive', 'dpkg', '-i', file.path]);
|
||||
if (context.mounted) {
|
||||
if (r.exitCode == 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('升级完成,请重启应用')));
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('安装失败:${r.stderr}')));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await Process.run('xdg-open', [file.parent.path]);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已下载到 ${file.path},请解压覆盖后重启应用')));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e')));
|
||||
}
|
||||
} finally {
|
||||
progress.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!Platform.isAndroid) return;
|
||||
if (rel.url.isEmpty) return;
|
||||
final progress = ValueNotifier<double>(0);
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('正在下载更新'),
|
||||
content: OtaProgress(progress: progress),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final file = await download(rel.url, (p) => progress.value = p);
|
||||
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
|
||||
await installApk(file.path);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e')));
|
||||
}
|
||||
} finally {
|
||||
progress.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OtaProgress extends StatefulWidget {
|
||||
const OtaProgress({super.key, required this.progress});
|
||||
final ValueListenable<double> progress;
|
||||
@override
|
||||
State<OtaProgress> createState() => _OtaProgressState();
|
||||
}
|
||||
|
||||
class _OtaProgressState extends State<OtaProgress> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.progress.addListener(_on);
|
||||
}
|
||||
|
||||
void _on() => setState(() {});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.progress.removeListener(_on);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = widget.progress.value;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(value: p <= 0 ? null : p, color: kBind),
|
||||
const SizedBox(height: 12),
|
||||
Text('${(p * 100).clamp(0, 100).toStringAsFixed(0)}%'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String prettyJson(Object v) {
|
||||
try {
|
||||
return const JsonEncoder.withIndent(' ').convert(v);
|
||||
} catch (_) {
|
||||
return '$v';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user