diff --git a/README.md b/README.md index 407c130..babcba1 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ -# desktop-plugin(饭卡打印站客户端 · Flutter 版) +# desktop-plugin(资安信插件助手) + +**资安信插件助手**:桌面插件集成软件(目录名 desktop-plugin,Dart 包名 desktop_plugin,bundle id com.zax.desktop-plugin,App 显示名"资安信插件助手")。当前内置第一个插件"饭卡打印机",后续插件在 `lib/ui/routes.dart` 注册即自动出现在左侧菜单。关闭窗口 = 最小化到后台托盘,服务持续运行;退出入口在托盘菜单。 用 Flutter 重写打印站客户端,替代 C# 版 card-printer-agent。HTTP 协议**完全不变**,前端(`biz-pc-cloud-zax/src/api/plugin/cardPrinter.ts`)零改动,两版助手可并存灰度替换。 -- 目录名 `desktop-plugin`,Dart 包名 `desktop_plugin`(Dart 标识符不允许连字符) -- macOS bundle id:`com.zax.desktop-plugin`(`macos/Runner/Configs/AppInfo.xcconfig`) +- 图标源:资安信 logo(`biz_zax_app_cloud/unpackage/res/icons/1024x1024.png` 深蓝盾牌+白色人形);`dart run tool/gen_app_icons.dart <源图>` 重新生成 mac AppIcon/Windows ICO/托盘图标 - 总体计划见 `../card-printer-agent/flutter-client-plan.md` ## 协议(与 C# 版逐字段对齐) @@ -24,7 +25,12 @@ ``` lib/ ├── main.dart # Flutter 窗口入口(服务状态 + 打印机状态 + 运行日志) -├── agent.dart # 核心组合:参数解析 + 日志缓冲 + 后端/队列/服务装配(纯 Dart,无 Flutter 依赖) +├── agent.dart # 核心组合:参数解析 + 日志缓冲 + 后端/队列/服务装配(纯 Dart,无 Flutter 依赖;stop 幂等) +├── ui/ +│ ├── routes.dart # 插件路由注册表(左侧菜单与内容页共用,加插件=加一项) +│ ├── app_shell.dart # 应用外壳:左侧插件菜单 + IndexedStack 内容区 +│ ├── card_printer_page.dart # 饭卡打印机插件页:主状态一眼可懂 + 指标卡 + 技术详情折叠 +│ └── app_tray.dart # 后台托盘:关窗隐藏(window_manager)/托盘菜单(tray_manager)/恢复窗口/退出 ├── server/ │ ├── protocol.dart # 协议 DTO(toJson 手写驼峰,对齐 C# ApiModels.cs) │ ├── http_server.dart # shelf 服务:端口顺延、CORS、四端点 @@ -75,12 +81,19 @@ flutter build macos --release ## 进度(2026-09-21) -- [x] Phase 1:协议服务器 + mock + 任务队列 + 端口顺延(单测 5 个全过,接口自测全过) +- [x] Phase 1:协议服务器 + mock + 任务队列 + 端口顺延(单测全过,接口自测全过) - [x] Phase 2:macOS CUPS 后端(真机 status `HiTi CS-200e, 2.4.2 就绪`;真实出卡一张,printer-state 4→3 约 24s) -- [x] Phase 3 代码:Windows PavoApi ffi 后端(mac 上完成编码,analyze 零问题 + 单测 12 个全过;windows/ 目录已从 SDK 模板手动实例化,vendor 收录 SDK DLL/bin 并在 CMake 构建后自动拷贝)——**待 Windows 实机验收**(编译/直打/色带/不装驱动直打验证) -- [ ] Phase 4:托盘/首次引导/URL scheme 唤起(zaxprint scheme 已写入 Info.plist)/开机自启 +- [x] Phase 3 代码:Windows PavoApi ffi 后端(analyze 零问题;windows/ 目录已从 SDK 模板手动实例化,vendor 收录 SDK DLL/bin 并在 CMake 构建后自动拷贝)——**待 Windows 实机验收** +- [x] Phase 4 部分:插件化 UI(左侧菜单=路由注册表,主状态/指标/技术详情三层信息分级)+ **后台托盘**(关窗不退出、托盘恢复窗口、退出仅托盘菜单)+ 资安信 logo 图标全家桶 + 改名"资安信插件助手" +- [ ] Phase 4 剩余:首次引导向导、URL scheme 唤起闭环(zaxprint scheme 已写入 Info.plist)、开机自启(LaunchAgent/HKCU Run) - [ ] Phase 5:打包签名分发(mac ad-hoc 就地签名;参考 C# 版签名铁律) +### 已知坑(本项目实测) +- SliverList 里 `Row(crossAxisAlignment: stretch)` 必须包 `IntrinsicHeight`,否则子项收到 h=Infinity 直接布局断言(test/ui_smoke_test.dart 守护) +- testWidgets 里 `agent.start()/stop()` 是真实 socket I/O,必须 `tester.runAsync` 包裹,否则 fake async zone 永久挂起;且勿用 pumpAndSettle(页面含动画组件永不 settle) +- image 包 `Image()` 默认 3 通道不透明黑(透明图须 numChannels:4);`compositeImage` 对全透明像素非 src-over 语义 +- tray_manager 0.5.3:无双击事件(单击即 MouseDown);菜单须先 setContextMenu 再无参 popUpContextMenu + ### Windows 实机验收清单(Phase 3 收尾) 1. `flutter create --platforms windows .` 对照生成的目录结构核对手动实例化的差异(或直接构建) 2. `flutter build windows --release` 编译过;确认 vendor 四文件拷到 exe 旁 diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..b5fa622 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/tray_icon_mac.png b/assets/tray_icon_mac.png new file mode 100644 index 0000000..e3da3e8 Binary files /dev/null and b/assets/tray_icon_mac.png differ diff --git a/assets/tray_icon_win.png b/assets/tray_icon_win.png new file mode 100644 index 0000000..01223ff Binary files /dev/null and b/assets/tray_icon_win.png differ diff --git a/lib/agent.dart b/lib/agent.dart index 7cdc2ce..40ada3d 100644 --- a/lib/agent.dart +++ b/lib/agent.dart @@ -74,12 +74,17 @@ class Agent { ); final ports = options.port == null ? const [38200, 38201, 38202] : [options.port!]; port = await server.start(candidatePorts: ports); - log.write('饭卡打印助手已启动 http://127.0.0.1:$port' + log.write('资安信插件助手已启动 http://127.0.0.1:$port' '(后端=$backendName,mock=${options.mock},agent=desktop-plugin)'); return port!; } + bool _stopped = false; + + /// 幂等停止:托盘退出与窗口销毁可能先后各调一次。 Future stop() async { + if (_stopped) return; + _stopped = true; await server.stop(); queue.dispose(); log.dispose(); diff --git a/lib/main.dart b/lib/main.dart index 4a47c9a..2b030f1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,17 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; import 'agent.dart'; -import 'server/protocol.dart'; +import 'ui/app_shell.dart'; +import 'ui/app_tray.dart'; -/// 饭卡打印助手主窗口(Phase 1 最小版:服务状态 + 打印机状态 + 运行日志; -/// 托盘/首次引导/驱动安装在后续 Phase 增强)。 +/// 资安信插件助手(desktop-plugin):桌面插件集成软件。 +/// 当前内置插件:饭卡打印机(card-printer);左侧菜单与插件页均由 lib/ui/routes.dart 驱动。 +/// 关闭窗口 = 最小化到后台托盘,服务持续运行;退出入口在托盘菜单。 Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + await windowManager.ensureInitialized(); + final agent = Agent(AgentOptions.parse(args)); String? startError; try { @@ -16,6 +19,10 @@ Future main(List args) async { } catch (e) { startError = '$e'; } + + final appTray = AppTray(agent: agent); + await appTray.start(); + runApp(DesktopPluginApp(agent: agent, startError: startError)); } @@ -28,176 +35,40 @@ class DesktopPluginApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: '饭卡打印助手', + title: '资安信插件助手', debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0F6FDE)), - useMaterial3: true, - ), - home: AgentHomePage(agent: agent, startError: startError), - ); - } -} - -class AgentHomePage extends StatefulWidget { - const AgentHomePage({super.key, required this.agent, this.startError}); - - final Agent agent; - final String? startError; - - @override - State createState() => _AgentHomePageState(); -} - -class _AgentHomePageState extends State { - PrinterStatus? _status; - Object? _statusError; - bool _loading = false; - StreamSubscription? _logSub; - final _logs = []; - - @override - void initState() { - super.initState(); - _logSub = widget.agent.log.stream.listen((line) { - if (mounted) setState(() => _logs.add(line)); - }); - _logs.addAll(widget.agent.log.lines); - _refreshStatus(); - } - - @override - void dispose() { - _logSub?.cancel(); - super.dispose(); - } - - Future _refreshStatus() async { - setState(() => _loading = true); - try { - final status = await widget.agent.backend.getStatus(); - if (!mounted) return; - setState(() { - _status = status; - _statusError = null; - }); - } catch (e) { - if (!mounted) return; - setState(() => _statusError = '$e'); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - final port = widget.agent.port; - return Scaffold( - appBar: AppBar( - title: const Text('饭卡打印助手'), - actions: [ - IconButton(icon: const Icon(Icons.refresh), tooltip: '刷新状态', onPressed: _refreshStatus), - const SizedBox(width: 8), - ], - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - _serviceCard(port), - const SizedBox(height: 12), - _printerCard(), - const SizedBox(height: 12), - _logCard(), - ], - ), - ); - } - - Widget _serviceCard(int? port) { - final err = widget.startError; - return Card( - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - err == null && port != null ? Icons.check_circle : Icons.error, - color: err == null && port != null ? Colors.green : Colors.red, - ), - const SizedBox(width: 8), - Text('本机服务', style: Theme.of(context).textTheme.titleMedium), - ], - ), - const SizedBox(height: 8), - Text(err ?? (port != null ? 'http://127.0.0.1:$port (管理端开卡页自动发现)' : '未启动')), - const SizedBox(height: 4), - Text('后端:${widget.agent.options.mock ? "mock(模拟)" : "系统打印队列"}'), - ], - ), - ), - ); - } - - Widget _printerCard() { - final status = _status; - final err = _statusError; - return Card( - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (_loading) - const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) - else - Icon( - status != null && status.online ? Icons.print : Icons.print_disabled, - color: status != null && status.online ? Colors.green : Colors.orange, - ), - const SizedBox(width: 8), - Text('打印机', style: Theme.of(context).textTheme.titleMedium), - ], - ), - const SizedBox(height: 8), - if (err != null) - Text('状态读取失败:$err', style: const TextStyle(color: Colors.red)) - else if (status != null) ...[ - Text('${status.model.isEmpty ? "未知型号" : status.model} · ${status.statusText}'), - const SizedBox(height: 4), - Text( - '色带余量 ${status.ribbon.remain >= 0 ? "${status.ribbon.remain}%" : "未知"}' - ' · 剩卡 ${status.cardLeft >= 0 ? "${status.cardLeft} 张" : "未知"}', - style: TextStyle(color: Theme.of(context).hintColor), - ), - ], - ], - ), - ), - ); - } - - Widget _logCard() { - return Card( - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('运行日志', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - if (_logs.isEmpty) - Text('(暂无)', style: TextStyle(color: Theme.of(context).hintColor)) - else - ...(_logs.length > 50 ? _logs.sublist(_logs.length - 50) : _logs) - .map((line) => Text(line, style: const TextStyle(fontFamily: 'monospace', fontSize: 12))), - ], - ), - ), + theme: _darkTheme(), + home: AppShell(agent: agent, startError: startError), + ); + } + + /// 深色运维风格(ui-ux-pro-max 设计系统:OLED 深底 + 运维三色语义)。 + ThemeData _darkTheme() { + final base = ThemeData(brightness: Brightness.dark, useMaterial3: true); + final scheme = base.colorScheme.copyWith( + // 正常/主操作:operational green + primary: const Color(0xFF4ADE80), + onPrimary: const Color(0xFF052E16), + primaryContainer: const Color(0xFF14532D), + onPrimaryContainer: const Color(0xFFDCFCE7), + // 故障红 + error: const Color(0xFFF87171), + errorContainer: const Color(0xFF7F1D1D), + onErrorContainer: const Color(0xFFFEE2E2), + // 表面层级:#0F172A 底 / #111827 卡 / #1E293B 强调 + surface: const Color(0xFF0F172A), + onSurface: const Color(0xFFF8FAFC), + onSurfaceVariant: const Color(0xFFCBD5E1), + surfaceContainerLow: const Color(0xFF111827), + surfaceContainerHighest: const Color(0xFF1E293B), + outlineVariant: const Color(0xFF334155), + ); + return base.copyWith( + colorScheme: scheme, + scaffoldBackgroundColor: const Color(0xFF0F172A), + cardTheme: const CardTheme(color: Color(0xFF111827)), + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + dividerTheme: const DividerThemeData(color: Color(0xFF334155), thickness: 1), ); } } diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart new file mode 100644 index 0000000..da96043 --- /dev/null +++ b/lib/ui/app_shell.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; + +import '../agent.dart'; +import 'routes.dart'; + +/// 应用外壳:左侧插件菜单 + 右侧插件页面(菜单项来自路由注册表)。 +class AppShell extends StatefulWidget { + const AppShell({super.key, required this.agent, this.startError}); + + final Agent agent; + final String? startError; + + @override + State createState() => _AppShellState(); +} + +class _AppShellState extends State { + int _selectedIndex = 0; + + Agent get _agent => widget.agent; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Scaffold( + body: Row( + children: [ + _sidebar(context, scheme), + VerticalDivider(width: 1, thickness: 1, color: scheme.outlineVariant), + Expanded(child: _content(context, scheme)), + ], + ), + ); + } + + // ---------------- 左侧菜单 ---------------- + + Widget _sidebar(BuildContext context, ColorScheme scheme) { + final serviceOnline = _agent.port != null; + return Container( + width: 216, + color: scheme.surfaceContainerLow, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 产品区 + Padding( + padding: const EdgeInsets.fromLTRB(16, 18, 16, 14), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset('assets/logo.png', width: 28, height: 28), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '资安信插件助手', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.2), + ), + Text('桌面插件', style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + _ServiceDot(online: serviceOnline), + ], + ), + ), + Divider(height: 1, thickness: 1, color: scheme.outlineVariant), + + // 插件菜单(来自路由注册表) + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 6), + child: Text( + '插件', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith(color: scheme.onSurfaceVariant), + ), + ), + for (var i = 0; i < appRoutes.length; i++) + _SidebarItem( + route: appRoutes[i], + selected: i == _selectedIndex, + onTap: () => setState(() => _selectedIndex = i), + ), + ], + ), + ), + + Divider(height: 1, thickness: 1, color: scheme.outlineVariant), + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), + child: Text( + 'v1.0.0', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: scheme.onSurfaceVariant), + ), + ), + ], + ), + ); + } + + // ---------------- 右侧内容 ---------------- + + Widget _content(BuildContext context, ColorScheme scheme) { + final error = widget.startError; + if (error != null) { + return _ServiceErrorView(message: error); + } + // IndexedStack 保持各插件页状态,切换菜单不丢页面状态 + return IndexedStack( + index: _selectedIndex, + children: [ + for (final route in appRoutes) + route.builder(context, _agent), + ], + ); + } +} + +/// 侧栏插件项:图标 + 标题/副标题 + 选中态(高亮底 + 左侧指示条)。 +class _SidebarItem extends StatelessWidget { + const _SidebarItem({required this.route, required this.selected, required this.onTap}); + + final AppRoute route; + final bool selected; + final VoidCallback onTap; + + static const double _minHeight = 46; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final fg = selected ? scheme.onSurface : scheme.onSurfaceVariant; + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Material( + color: selected ? scheme.primaryContainer : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + hoverColor: scheme.primaryContainer.withValues(alpha: 0.35), + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: _minHeight), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Container( + width: 3, + height: 22, + decoration: BoxDecoration( + color: selected ? scheme.primary : Colors.transparent, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 10), + Icon(route.icon, size: 20, color: selected ? scheme.primary : fg), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + route.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: fg, fontWeight: selected ? FontWeight.w600 : null), + ), + Text( + route.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: scheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 服务在线小圆点(带可达性语义,不只靠颜色)。 +class _ServiceDot extends StatelessWidget { + const _ServiceDot({required this.online}); + + final bool online; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Tooltip( + message: online ? '本机服务运行中' : '本机服务未运行', + child: Icon( + online ? Icons.check_circle : Icons.cancel, + size: 16, + color: online ? scheme.primary : scheme.error, + ), + ); + } +} + +/// 服务启动失败整页提示(引导重试/看日志)。 +class _ServiceErrorView extends StatelessWidget { + const _ServiceErrorView({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.error_outline_rounded, color: scheme.error, size: 32), + const SizedBox(width: 12), + Text('本机服务启动失败', style: Theme.of(context).textTheme.titleLarge), + ], + ), + const SizedBox(height: 12), + Text( + message, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 16), + Text( + '常见原因:多个助手实例同时运行占用了端口。关闭其他实例后重新打开本应用即可。', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/app_tray.dart b/lib/ui/app_tray.dart new file mode 100644 index 0000000..4fb90be --- /dev/null +++ b/lib/ui/app_tray.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../agent.dart'; + +/// 后台托盘:点窗口关闭按钮 = 隐藏窗口进后台(服务继续运行), +/// 托盘常驻(mac 菜单栏 template 图标 / Windows 系统托盘彩色图标), +/// 单击/双击托盘恢复窗口,右键弹菜单,退出入口只放在托盘菜单里。 +class AppTray with TrayListener, WindowListener { + AppTray({required this.agent}); + + final Agent agent; + + static const _menuShow = 'menu_show'; + static const _menuExit = 'menu_exit'; + + bool _started = false; + + Future start() async { + if (_started) return; + _started = true; + windowManager.addListener(this); + await windowManager.setPreventClose(true); + + await trayManager.setIcon( + Platform.isMacOS ? 'assets/tray_icon_mac.png' : 'assets/tray_icon_win.png', + ); + await trayManager.setToolTip('资安信插件助手 · 饭卡打印(后台运行中)'); + await trayManager.setContextMenu(_menu); + trayManager.addListener(this); + agent.log.write('托盘已就绪:关闭窗口将最小化到托盘,服务持续运行'); + } + + // ---------------- 窗口事件 ---------------- + + @override + void onWindowClose() async { + // 关窗不退出:隐藏窗口,服务与托盘保持运行 + await windowManager.hide(); + } + + // ---------------- 托盘事件 ---------------- + + @override + void onTrayIconMouseDown() => _showWindow(); + + @override + void onTrayIconRightMouseDown() { + trayManager.popUpContextMenu(); + } + + @override + void onTrayMenuItemClick(MenuItem menuItem) async { + switch (menuItem.key) { + case _menuShow: + await _showWindow(); + case _menuExit: + await _exitApp(); + } + } + + Menu get _menu => Menu( + items: [ + MenuItem(key: _menuShow, label: '显示主界面'), + MenuItem.separator(), + MenuItem(key: _menuExit, label: '退出'), + ], + ); + + Future _showWindow() async { + await windowManager.show(); + await windowManager.focus(); + } + + Future _exitApp() async { + agent.log.write('用户从托盘退出,停止服务'); + await agent.stop(); + await trayManager.destroy(); + await windowManager.destroy(); + } +} diff --git a/lib/ui/card_printer_page.dart b/lib/ui/card_printer_page.dart new file mode 100644 index 0000000..9e9a6d0 --- /dev/null +++ b/lib/ui/card_printer_page.dart @@ -0,0 +1,559 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../agent.dart'; +import '../server/protocol.dart'; + +/// 饭卡打印机插件页:主状态一眼可懂,技术参数收进"技术详情"。 +class CardPrinterPage extends StatefulWidget { + const CardPrinterPage({super.key, required this.agent}); + + final Agent agent; + + @override + State createState() => _CardPrinterPageState(); +} + +class _CardPrinterPageState extends State { + PrinterStatus? _status; + String? _statusError; + bool _loading = false; + StreamSubscription? _logSub; + final _logs = []; + + Agent get _agent => widget.agent; + + @override + void initState() { + super.initState(); + _logSub = _agent.log.stream.listen((line) { + if (mounted) setState(() => _logs.add(line)); + }); + _logs.addAll(_agent.log.lines); + _refreshStatus(); + } + + @override + void dispose() { + _logSub?.cancel(); + super.dispose(); + } + + Future _refreshStatus() async { + setState(() => _loading = true); + try { + final status = await _agent.backend.getStatus(); + if (!mounted) return; + setState(() { + _status = status; + _statusError = null; + }); + } catch (e) { + if (!mounted) return; + setState(() => _statusError = '$e'); + } finally { if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: scheme.surface, + body: SelectionArea( + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 24), + sliver: SliverList( + delegate: SliverChildListDelegate([ + _header(context), + const SizedBox(height: 20), + _statusHeroCard(context), + const SizedBox(height: 14), + _metricsRow(context), + const SizedBox(height: 14), + _techDetailsCard(context), + ]), + ), + ), + ], + ), + ), + ); + } + + // ---------------- 头部:插件名 + 服务徽章 + 刷新 ---------------- + + Widget _header(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final serviceOnline = _agent.port != null; + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('饭卡打印机', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 2), + Text( + '管理端开卡页提交的卡面会自动发送到本机打印机', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: scheme.onSurfaceVariant), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: serviceOnline ? scheme.primaryContainer : scheme.errorContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + serviceOnline ? Icons.check_circle : Icons.error_outline, + size: 14, + color: serviceOnline ? scheme.primary : scheme.error, + ), + const SizedBox(width: 6), + Text( + serviceOnline ? '服务运行中' : '服务未运行', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: serviceOnline ? scheme.onPrimaryContainer : scheme.onErrorContainer, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + IconButton.filledTonal( + onPressed: _loading ? null : _refreshStatus, + tooltip: '刷新打印机状态', + icon: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + ), + ], + ); + } + + // ---------------- 状态主卡:一眼看懂 ---------------- + + /// 主状态归纳:普通用户只需要知道"能不能用、现在干嘛"。 + _Headline _headline() { + final s = _status; + if (s == null) { + if (_statusError != null) { + return _Headline('状态读取失败', _StateColor.attention, Icons.help_outline_rounded); + } + return _Headline('正在获取状态…', _StateColor.idle, Icons.hourglass_empty); + } + if (!s.online) { + return _Headline('打印机未连接', _StateColor.error, Icons.print_disabled); + } + if (s.status == 0) { + return _Headline('打印机就绪', _StateColor.ok, Icons.print_rounded); + } + if (s.status == 0x00000002 || s.status == 0x00000005 || s.status == 0x00000006) { + return _Headline('正在打印', _StateColor.ok, Icons.print_rounded); + } + return _Headline('需要处理', _StateColor.attention, Icons.report_problem_outlined); + } + + Widget _statusHeroCard(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final head = _headline(); + final s = _status; + final color = switch (head.color) { + _StateColor.ok => scheme.primary, + _StateColor.attention => const Color(0xFFD97706), + _StateColor.error => scheme.error, + _StateColor.idle => scheme.onSurfaceVariant, + }; + return Card( + margin: EdgeInsets.zero, + elevation: 0, + color: scheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: scheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(16), + ), + child: Icon(head.icon, size: 34, color: color), + ), + const SizedBox(width: 18), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(head.label, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 4), + Text( + s == null + ? (_statusError ?? '正在读取打印机状态') + : (s.statusText.isEmpty ? '—' : s.statusText), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: scheme.onSurfaceVariant), + ), + if (s != null && s.model.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + s.model, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + // ---------------- 指标行:色带 / 卡量(生活化表述) ---------------- + + Widget _metricsRow(BuildContext context) { + // SliverList 内高度无界,stretch 需先经 IntrinsicHeight 拿到有界高度, + // 否则子项会收到 h=Infinity 的约束直接布局断言失败 + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _ribbonCard(context)), + const SizedBox(width: 14), + Expanded(child: _cardStockCard(context)), + ], + ), + ); + } + + Widget _ribbonCard(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final s = _status; + final remain = s?.ribbon.remain ?? -1; + final known = remain >= 0; + final color = !known + ? scheme.onSurfaceVariant + : remain <= 10 + ? scheme.error + : remain <= 30 + ? const Color(0xFFD97706) + : scheme.primary; + return _MetricCard( + icon: Icons.style_outlined, + title: '色带余量', + child: known + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$remain%', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: color, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: remain / 100, + minHeight: 6, + backgroundColor: scheme.surfaceContainerHighest, + color: color, + ), + ), + if (remain <= 10) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '色带即将用尽,请准备更换', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: color), + ), + ), + ], + ) + : _unknownText(context, '由打印机驱动提供,当前不可读'), + ); + } + + Widget _cardStockCard(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final s = _status; + final left = s?.cardLeft ?? -1; + final low = s?.cardLow ?? false; + final known = left >= 0; + final color = low ? const Color(0xFFD97706) : scheme.onSurfaceVariant; + return _MetricCard( + icon: Icons.layers_outlined, + title: '进卡仓卡量', + child: known + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '剩余 $left 张', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + if (low) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '卡量不足,请及时补充空白卡', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: color), + ), + ), + ], + ) + : _unknownText(context, '由打印机驱动提供,当前不可读'), + ); + } + + Widget _unknownText(BuildContext context, String hint) => Builder( + builder: (context) { + final scheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '未知', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 6), + Text( + hint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ); + }, + ); + + // ---------------- 技术详情(折叠收纳) ---------------- + + Widget _techDetailsCard(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final s = _status; + return Card( + margin: EdgeInsets.zero, + elevation: 0, + color: scheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: scheme.outlineVariant), + ), + clipBehavior: Clip.antiAlias, + child: Theme( + // 去掉 ExpansionTile 默认分割线密度,保持卡片内干净 + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + controlAffinity: ListTileControlAffinity.trailing, + title: Text('技术详情', style: Theme.of(context).textTheme.titleSmall), + subtitle: Text( + '服务地址、后端模式、设备信息与运行日志', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + children: [ + Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DetailSection(title: '本机服务', rows: [ + ('运行状态', _agent.port != null ? '运行中' : '未运行'), + ('服务地址', _agent.port != null ? 'http://127.0.0.1:${_agent.port}' : '—'), + ('打印通道', _agent.options.mock ? '模拟(测试用)' : '系统打印通道'), + ]), + const SizedBox(height: 14), + _DetailSection(title: '设备信息', rows: [ + ('型号', s == null || s.model.isEmpty ? '—' : s.model), + ('固件版本', s != null && s.firmware.isNotEmpty ? s.firmware : '—'), + ('连接方式', s?.connection ?? '—'), + ('状态码', s == null ? '—' : '0x${s.status.toRadixString(16).padLeft(8, '0').toUpperCase()}'), + ('色带类型', s != null && s.ribbon.type.isNotEmpty ? s.ribbon.type : '—'), + ('色带剩余张数', s != null && s.ribbon.remainCount >= 0 ? '${s.ribbon.remainCount} 张' : '—'), + ]), + const SizedBox(height: 14), + Text('运行日志(最近 50 条)', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + )), + const SizedBox(height: 6), + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 220), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: _logs.isEmpty + ? Text('(暂无日志)', + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)) + : ListView( + children: [ + for (final line in (_logs.length > 50 ? _logs.sublist(_logs.length - 50) : _logs)) + Text( + line, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11.5, + height: 1.5, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 指标小卡容器。 +class _MetricCard extends StatelessWidget { + const _MetricCard({required this.icon, required this.title, required this.child}); + + final IconData icon; + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Card( + margin: EdgeInsets.zero, + elevation: 0, + color: scheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: scheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: scheme.onSurfaceVariant), + const SizedBox(width: 6), + Text(title, style: Theme.of(context).textTheme.labelMedium), + ], + ), + const SizedBox(height: 10), + child, + ], + ), + ), + ); + } +} + +/// 详情分组:标题 + 标签值行。 +class _DetailSection extends StatelessWidget { + const _DetailSection({required this.title, required this.rows}); + + final String title; + final List<(String, String)> rows; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + )), + const SizedBox(height: 6), + Table( + columnWidths: const { + 0: FixedColumnWidth(120), + 1: FlexColumnWidth(), + }, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + for (final (label, value) in rows) + TableRow(children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Text(value, style: Theme.of(context).textTheme.bodySmall), + ), + ]), + ], + ), + ], + ); + } +} + +enum _StateColor { ok, attention, error, idle } + +class _Headline { + const _Headline(this.label, this.color, this.icon); + + final String label; + final _StateColor color; + final IconData icon; +} diff --git a/lib/ui/routes.dart b/lib/ui/routes.dart new file mode 100644 index 0000000..9893434 --- /dev/null +++ b/lib/ui/routes.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import '../agent.dart'; +import 'card_printer_page.dart'; + +/// 桌面插件路由:左侧菜单与内容页共用这一份注册表,加第二个插件 = 在这里加一项。 +class AppRoute { + const AppRoute({ + required this.id, + required this.title, + required this.subtitle, + required this.icon, + required this.builder, + }); + + /// 与助手 manifest.plugins[].id 对应(card-printer / 未来的其他插件) + final String id; + final String title; + final String subtitle; + final IconData icon; + final Widget Function(BuildContext context, Agent agent) builder; +} + +/// 插件注册表(顺序即菜单顺序)。 +final appRoutes = [ + AppRoute( + id: 'card-printer', + title: '饭卡打印机', + subtitle: '开卡页面卡打印', + icon: Icons.credit_card_outlined, + builder: (context, agent) => CardPrinterPage(agent: agent), + ), +]; diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..641dc94 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,12 @@ import FlutterMacOS import Foundation +import screen_retriever_macos +import tray_manager +import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..29c8eb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..b0c93c4 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,34 @@ +PODS: + - FlutterMacOS (1.0.0) + - screen_retriever_macos (0.0.1): + - FlutterMacOS + - tray_manager (0.0.1): + - FlutterMacOS + - window_manager (0.5.0): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) + - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) + - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + screen_retriever_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos + tray_manager: + :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos + window_manager: + :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos + +SPEC CHECKSUMS: + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + screen_retriever_macos: c5508cc3c66ff0d4db650480cf0ab691e220d933 + tray_manager: 19c369010e59c073f13b82354d78a35f7972d609 + window_manager: b729e31d38fb04905235df9ea896128991cad99e + +PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82 + +COCOAPODS: 1.17.0 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 2257b27..1befa82 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 5781D0E199CA55AED43F228A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F40FB10CA14CC78079CDC7BB /* Pods_Runner.framework */; }; + 774C26C386F7050861BF735F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475C3B0B9A2683B25B178A9A /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 2AD0EB92854F550FEE0774AF /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* desktop_plugin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "desktop_plugin.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* desktop_plugin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = desktop_plugin.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +79,15 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 475C3B0B9A2683B25B178A9A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 68F792FC55819A12D0153811 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 6B06F6041FBE212AEED111F1 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 95A2EEE31E406D5034E815E4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + C965AD6ABEFBA556B193545C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + E20CF4159CC015CF60A5436A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + F40FB10CA14CC78079CDC7BB /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 774C26C386F7050861BF735F /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5781D0E199CA55AED43F228A /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 4C3798D6517B50224A630B7A /* Pods */, ); sourceTree = ""; }; @@ -172,9 +185,25 @@ path = Runner; sourceTree = ""; }; + 4C3798D6517B50224A630B7A /* Pods */ = { + isa = PBXGroup; + children = ( + 68F792FC55819A12D0153811 /* Pods-Runner.debug.xcconfig */, + C965AD6ABEFBA556B193545C /* Pods-Runner.release.xcconfig */, + E20CF4159CC015CF60A5436A /* Pods-Runner.profile.xcconfig */, + 6B06F6041FBE212AEED111F1 /* Pods-RunnerTests.debug.xcconfig */, + 2AD0EB92854F550FEE0774AF /* Pods-RunnerTests.release.xcconfig */, + 95A2EEE31E406D5034E815E4 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + F40FB10CA14CC78079CDC7BB /* Pods_Runner.framework */, + 475C3B0B9A2683B25B178A9A /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 688F23D3623E5440FD86FCCB /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 36C53FF096430D2D46751674 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + D6E22F5B918FE0FC284A291E /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -329,6 +361,67 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 36C53FF096430D2D46751674 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 688F23D3623E5440FD86FCCB /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + D6E22F5B918FE0FC284A291E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,12 +473,13 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 6B06F6041FBE212AEED111F1 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.zax.desktop-plugin.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = "com.zax.desktop-plugin.RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/desktop_plugin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/desktop_plugin"; @@ -394,12 +488,13 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 2AD0EB92854F550FEE0774AF /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.zax.desktop-plugin.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = "com.zax.desktop-plugin.RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/desktop_plugin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/desktop_plugin"; @@ -408,12 +503,13 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 95A2EEE31E406D5034E815E4 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.zax.desktop-plugin.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = "com.zax.desktop-plugin.RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/desktop_plugin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/desktop_plugin"; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d..840cfcf 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eb..cbe7f42 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa..27be59a 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bdb5722..7993574 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index f083318..45199f7 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e7..603abc9 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632c..0cf8dd3 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib index 80e867a..83db063 100644 --- a/macos/Runner/Base.lproj/MainMenu.xib +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -330,12 +330,12 @@ - + - + - + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index eef706a..96b46bf 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,7 +5,7 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = desktop-plugin +PRODUCT_NAME = 资安信插件助手 // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.zax.desktop-plugin diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3cc05eb..cc3974e 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -7,6 +7,7 @@ class MainFlutterWindow: NSWindow { let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) + self.minSize = NSSize(width: 860, height: 560) RegisterGeneratedPlugins(registry: flutterViewController) diff --git a/pubspec.lock b/pubspec.lock index 2fb4af2..837d52a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -115,6 +115,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.10.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -163,6 +171,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" meta: dependency: transitive description: @@ -179,6 +195,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" posix: dependency: transitive description: @@ -187,6 +211,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.2" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" shelf: dependency: "direct main" description: @@ -203,6 +267,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.4" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" sky_engine: dependency: transitive description: flutter @@ -256,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + tray_manager: + dependency: "direct main" + description: + name: tray_manager + sha256: "1a659b08baa6e9b91ef8ce16eda37740de398be1c4cf322b8a1ddfef25c68c5a" + url: "https://pub.dev" + source: hosted + version: "0.5.3" typed_data: dependency: transitive description: @@ -280,6 +360,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.1" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" sdks: dart: ">=3.7.2 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml index dc2aadb..e41fe68 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,9 @@ dependencies: image: ^4.3.0 # dart:ffi 辅助(Utf16 编解码与内存分配,Windows PavoApi 直打用) ffi: ^2.1.0 + # 后台托盘与窗口控制(关窗不退出、托盘常驻) + tray_manager: ^0.5.3 + window_manager: ^0.5.2 dev_dependencies: flutter_test: @@ -65,7 +68,14 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: + # 托盘图标(白底圆盘+logo,深浅菜单栏/任务栏均可见)与侧栏 logo + assets: + - assets/tray_icon_mac.png + - assets/tray_icon_win.png + - assets/logo.png + +# To add assets to your application, add an assets section to your flutter +# configuration like this: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg diff --git a/test/ui_smoke_test.dart b/test/ui_smoke_test.dart new file mode 100644 index 0000000..9b3383b --- /dev/null +++ b/test/ui_smoke_test.dart @@ -0,0 +1,36 @@ +import 'package:desktop_plugin/agent.dart'; +import 'package:desktop_plugin/ui/app_shell.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// UI 冒烟:真实装配 Agent + AppShell,捕获布局/渲染异常。 +/// 注意:Agent 的端口绑定/关停是真实 socket I/O,必须放 tester.runAsync +/// 让真实 event loop 驱动(fake async zone 里 await 真实 I/O 会永久挂起)。 +void main() { + testWidgets('AppShell + 饭卡打印机页布局无异常', (tester) async { + final agent = Agent(AgentOptions(mock: true, port: 38295)); + await tester.runAsync(() => agent.start()); + addTearDown(() => tester.runAsync(() => agent.stop())); + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(brightness: Brightness.dark, useMaterial3: true), + home: AppShell(agent: agent), + )); + + // 用显式 pump 推帧(不用 pumpAndSettle:页面含持续动画组件时在 fake clock 下永不能 settle) + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('饭卡打印机'), findsWidgets); + expect(find.text('技术详情'), findsOneWidget); + + // 展开技术详情,覆盖详情区(Table/日志)布局 + await tester.tap(find.text('技术详情')); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + + // 显式停掉常驻 Timer(清理定时器),避免测试框架的 pending timer 断言 + await tester.runAsync(() => agent.stop()); + }); +} diff --git a/tool/gen_app_icons.dart b/tool/gen_app_icons.dart new file mode 100644 index 0000000..7e68266 --- /dev/null +++ b/tool/gen_app_icons.dart @@ -0,0 +1,143 @@ +// 生成全部应用图标(源:资安信 logo 1024x1024 深蓝盾牌+白色人形,透明底) +// dart run tool/gen_app_icons.dart <源图路径> +// 产物: +// macos/Runner/Assets.xcassets/AppIcon.appiconset/*.png (mac App 图标,白底圆角托+盾牌) +// windows/runner/resources/app_icon.ico (16~256 多尺寸 PNG-in-ICO) +// assets/tray_icon_mac.png / assets/tray_icon_win.png (托盘:白底圆盘+盾牌,深浅菜单栏均可见) +// assets/logo.png (原样拷贝,侧栏显示用) +// ignore_for_file: avoid_print +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:image/image.dart'; + +/// 是否在圆角矩形外(>0 外) +bool _cornerOutside(int x, int y, int x0, int y0, int x1, int y1, int r) { + final cx = x < x0 + r ? x0 + r : (x > x1 - r ? x1 - r : x); + final cy = y < y0 + r ? y0 + r : (y > y1 - r ? y1 - r : y); + final dx = x - cx, dy = y - cy; + if (dx > 0 || dy > 0) { + return dx * dx + dy * dy > r * r; + } + return false; +} + +void main(List args) { + final src = args.isNotEmpty ? args.first : 'logo-1024.png'; + final logo = decodeImage(File(src).readAsBytesSync())!; + print('源图 ${logo.width}x${logo.height}'); + + // ---------- mac App 图标:白底圆角方托 + 盾牌居中 72% ---------- + Image macIcon(int size) { + final img = Image(width: size, height: size, numChannels: 4); + final r = (size * 0.18).round(); // macOS squircle 风格圆角 + final inset = (size * 0.14).round(); + final inner = copyResize(logo, width: size - inset * 2); + for (var y = 0; y < size; y++) { + for (var x = 0; x < size; x++) { + if (!_cornerOutside(x, y, 0, 0, size - 1, size - 1, r)) { + final hasPixel = x >= inset && + x < inset + inner.width && + y >= inset && + y < inset + inner.height; + final p = hasPixel ? inner.getPixel(x - inset, y - inset) : null; + if (p != null && p.a.toInt() > 0) { + img.setPixel(x, y, p); + } else { + img.setPixel(x, y, ColorRgba8(255, 255, 255, 255)); + } + } + } + } + return img; + } + + final appIconDir = Directory('macos/Runner/Assets.xcassets/AppIcon.appiconset'); + const macSizes = { + 'app_icon_16.png': 16, + 'app_icon_32.png': 32, + 'app_icon_64.png': 64, + 'app_icon_128.png': 128, + 'app_icon_256.png': 256, + 'app_icon_512.png': 512, + 'app_icon_1024.png': 1024, + }; + for (final e in macSizes.entries) { + final f = File('${appIconDir.path}/${e.key}'); + if (!f.existsSync()) { + print('跳过(Contents.json 未声明):${e.key}'); + continue; + } + f.writeAsBytesSync(encodePng(macIcon(e.value))); + print('written ${f.path} (${e.value}px)'); + } + + // ---------- Windows ICO(PNG-in-ICO) ---------- + Uint8List buildIco(List sizes) { + final pngs = {}; + for (final s in sizes) { + pngs[s] = Uint8List.fromList(encodePng(macIcon(s))); + } + final b = BytesBuilder(); + b.addByte(0); b.addByte(0); // reserved + b.addByte(1); b.addByte(0); // type = icon + b.addByte(sizes.length); b.addByte(0); // count + var offset = 6 + 16 * sizes.length; + for (final s in sizes) { + final png = pngs[s]!; + b.addByte(s >= 256 ? 0 : s); // width + b.addByte(s >= 256 ? 0 : s); // height + b.addByte(0); // colors + b.addByte(0); // reserved + b.addByte(1); b.addByte(0); // planes + b.addByte(32); b.addByte(0); // bitcount + for (var i = 0; i < 4; i++) { + b.addByte((png.length >> (8 * i)) & 0xff); + } + for (var i = 0; i < 4; i++) { + b.addByte((offset >> (8 * i)) & 0xff); + } + offset += png.length; + } + for (final s in sizes) { + b.add(pngs[s]!); + } + return b.toBytes(); + } + + final ico = buildIco([16, 24, 32, 48, 64, 128, 256]); + File('windows/runner/resources/app_icon.ico').writeAsBytesSync(ico); + print('written windows/runner/resources/app_icon.ico (${ico.length} bytes)'); + + // ---------- 托盘:白底圆盘 + 盾牌居中 ---------- + Image trayIcon(int size) { + final img = Image(width: size, height: size, numChannels: 4); + final c = (size - 1) / 2; + final rOuter = c; + final inset = (size * 0.13).round(); + final inner = copyResize(logo, width: size - inset * 2); + for (var y = 0; y < size; y++) { + for (var x = 0; x < size; x++) { + final d = ((x - c) * (x - c) + (y - c) * (y - c)); + if (d > rOuter * rOuter) continue; // 圆外透明 + final hasPixel = x >= inset && x < inset + inner.width && y >= inset && y < inset + inner.height; + final p = hasPixel ? inner.getPixel(x - inset, y - inset) : null; + if (p != null && p.a.toInt() > 0) { + img.setPixel(x, y, p); + } else { + img.setPixel(x, y, ColorRgba8(255, 255, 255, 255)); + } + } + } + return img; + } + + Directory('assets').createSync(recursive: true); + File('assets/tray_icon_mac.png').writeAsBytesSync(encodePng(trayIcon(64))); + File('assets/tray_icon_win.png').writeAsBytesSync(encodePng(trayIcon(32))); + print('written assets/tray_icon_mac.png / tray_icon_win.png'); + + // ---------- 原样 logo ---------- + File('assets/logo.png').writeAsBytesSync(encodePng(logo)); + print('written assets/logo.png'); +} diff --git a/tool/gen_tray_icon.dart b/tool/gen_tray_icon.dart new file mode 100644 index 0000000..1440075 --- /dev/null +++ b/tool/gen_tray_icon.dart @@ -0,0 +1,59 @@ +// 生成托盘图标(32x32 PNG) +// dart run tool/gen_tray_icon.dart +// - assets/tray_icon_template.png:macOS template 图标(纯黑+alpha,系统按菜单栏深浅自动反色) +// - assets/tray_icon_win.png:Windows 彩色图标(绿底+深色卡片剪影) +import 'dart:io'; + +import 'package:image/image.dart'; + +final _black = ColorRgba8(0, 0, 0, 255); +final _green = ColorRgba8(74, 222, 128, 255); // 主操作绿 #4ADE80 +final _navy = ColorRgba8(15, 23, 42, 255); // 深蓝黑 #0F172A + +bool _inRoundRect(int x, int y, int x0, int y0, int x1, int y1, int r) { + if (x < x0 || x > x1 || y < y0 || y > y1) return false; + final nearLeft = x < x0 + r, nearRight = x > x1 - r; + final nearTop = y < y0 + r, nearBottom = y > y1 - r; + if (!(nearLeft || nearRight) || !(nearTop || nearBottom)) return true; // 十字区 + final cx = nearLeft ? x0 + r : x1 - r; + final cy = nearTop ? y0 + r : y1 - r; + final dx = x - cx, dy = y - cy; + return dx * dx + dy * dy <= r * r; +} + +/// 卡片剪影(含磁条挖空)画到 img 上 +void _drawCard(Image img, int x0, int y0, int x1, int y1, ColorUint8 color) { + const r = 2; + final stripeY0 = y0 + (y1 - y0 + 1) ~/ 3; + final stripeY1 = stripeY0 + 2; + for (var y = y0; y <= y1; y++) { + for (var x = x0; x <= x1; x++) { + if (!_inRoundRect(x, y, x0, y0, x1, y1, r)) continue; + if (y >= stripeY0 && y <= stripeY1) continue; // 磁条挖空 + img.setPixel(x, y, color); + } + } +} + +void main() { + // macOS template:透明底 + 黑卡片 + final mac = Image(width: 32, height: 32, numChannels: 4); + _drawCard(mac, 5, 8, 26, 23, _black); + + // Windows:绿色圆角底 + 深色卡片 + final win = Image(width: 32, height: 32, numChannels: 4); + for (var y = 1; y <= 30; y++) { + for (var x = 1; x <= 30; x++) { + if (_inRoundRect(x, y, 1, 1, 30, 30, 6)) win.setPixel(x, y, _green); + } + } + _drawCard(win, 7, 9, 24, 22, _navy); + + for (final entry in [('assets/tray_icon_template.png', mac), ('assets/tray_icon_win.png', win)]) { + final file = File(entry.$1); + file.parent.createSync(recursive: true); + file.writeAsBytesSync(encodePng(entry.$2)); + // ignore: avoid_print + print('written ${entry.$1}'); + } +} diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 428a13a..ebfccee 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -84,18 +84,21 @@ VS_VERSION_INFO VERSIONINFO FILEOS VOS__WINDOWS32 FILETYPE VFT_APP FILESUBTYPE 0x0L +// UTF-8 源文件中文字符串需要显式声明代码页,否则 rc.exe 按本地代码页解码导致乱码 +#pragma code_page(65001) + BEGIN BLOCK "StringFileInfo" BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "com.zax" "\0" - VALUE "FileDescription", "desktop_plugin" "\0" + VALUE "FileDescription", "资安信插件助手" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "desktop_plugin" "\0" - VALUE "LegalCopyright", "Copyright (C) 2026 com.zax. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 zax. All rights reserved." "\0" VALUE "OriginalFilename", "desktop_plugin.exe" "\0" - VALUE "ProductName", "desktop_plugin" "\0" + VALUE "ProductName", "资安信插件助手" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 7f6b39d..020729f 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -26,8 +26,8 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.Create(L"饭卡打印助手", origin, size)) { + Win32Window::Size size(1000, 660); + if (!window.Create(L"资安信插件助手", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index e69de29..ec8247b 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ