feat: 后台托盘 + 资安信品牌化 + 修复布局断言(Phase 4 部分)

- 后台托盘(mac/Windows):window_manager 拦截关窗改隐藏,服务持续运行;
  托盘单击恢复窗口、右键菜单(显示主界面/退出),退出入口仅放托盘菜单
- 品牌:App 更名"资安信插件助手"(mac PRODUCT_NAME/xib、win main.cpp+Runner.rc(code_page 65001)、
  MaterialApp/侧栏/托盘 tooltip/日志);图标全家桶换资安信盾牌 logo
  (tool/gen_app_icons.dart 生成 mac AppIcon 7 尺寸/win ICO 7 尺寸/托盘白底圆盘版/assets logo)
- 修复启动布局断言:SliverList 内 Row(stretch) 需 IntrinsicHeight 包裹,
  否则子项收到 h=Infinity 约束(用户实测报错,test/ui_smoke_test.dart 守护复现)
- Agent.stop 幂等(托盘退出与窗口销毁竞态安全)
- 测试修复:smoke 测试改 tester.runAsync 包真实 socket I/O(fake async zone 里会永久挂起)、
  显式 pump 替代 pumpAndSettle;13 个测试全过,analyze 零问题,release 构建实测通过
This commit is contained in:
dzc committed 2026-09-21 13:16:23 +08:00
1 parent 0a78ff1b58
commit bee734fd5b
35 files changed
+1554 -196

No files matched your search

+20 -7
View File
@@ -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 旁
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 B

+6 -1
View File
@@ -74,12 +74,17 @@ class Agent {
);
final ports = options.port == null ? const [38200, 38201, 38202] : <int>[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<void> stop() async {
if (_stopped) return;
_stopped = true;
await server.stop();
queue.dispose();
log.dispose();
+45 -174
View File
@@ -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<void> main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized();
final agent = Agent(AgentOptions.parse(args));
String? startError;
try {
@@ -16,6 +19,10 @@ Future<void> main(List<String> 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<AgentHomePage> createState() => _AgentHomePageState();
}
class _AgentHomePageState extends State<AgentHomePage> {
PrinterStatus? _status;
Object? _statusError;
bool _loading = false;
StreamSubscription<String>? _logSub;
final _logs = <String>[];
@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<void> _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),
);
}
}
+271
View File
@@ -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<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
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,
),
],
),
),
);
}
}
+83
View File
@@ -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<void> 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<void> _showWindow() async {
await windowManager.show();
await windowManager.focus();
}
Future<void> _exitApp() async {
agent.log.write('用户从托盘退出,停止服务');
await agent.stop();
await trayManager.destroy();
await windowManager.destroy();
}
}
+559
View File
@@ -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<CardPrinterPage> createState() => _CardPrinterPageState();
}
class _CardPrinterPageState extends State<CardPrinterPage> {
PrinterStatus? _status;
String? _statusError;
bool _loading = false;
StreamSubscription<String>? _logSub;
final _logs = <String>[];
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<void> _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;
}
+33
View File
@@ -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>[
AppRoute(
id: 'card-printer',
title: '饭卡打印机',
subtitle: '开卡页面卡打印',
icon: Icons.credit_card_outlined,
builder: (context, agent) => CardPrinterPage(agent: agent),
),
];
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
@@ -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"))
}
+42
View File
@@ -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
+34
View File
@@ -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
+100 -4
View File
@@ -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 = "<group>"; };
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 = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
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 = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@@ -76,8 +79,15 @@
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>";
};
@@ -172,9 +185,25 @@
path = Runner;
sourceTree = "<group>";
};
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 = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
F40FB10CA14CC78079CDC7BB /* Pods_Runner.framework */,
475C3B0B9A2683B25B178A9A /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -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";
+3
View File
@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+3 -3
View File
@@ -330,12 +330,12 @@
</items>
<point key="canvasLocation" x="142" y="-258"/>
</menu>
<window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
<window title="资安信插件助手" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<rect key="contentRect" x="335" y="390" width="800" height="600"/>
<rect key="contentRect" x="335" y="390" width="1000" height="660"/>
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
<rect key="frame" x="0.0" y="0.0" width="1000" height="660"/>
<autoresizingMask key="autoresizingMask"/>
</view>
</window>
+1 -1
View File
@@ -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
+1
View File
@@ -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)
+88
View File
@@ -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"
+11 -1
View File
@@ -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
+36
View File
@@ -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());
});
}
+143
View File
@@ -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<String> 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 = <String, int>{
'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<int> sizes) {
final pngs = <int, Uint8List>{};
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');
}
+59
View File
@@ -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}');
}
}
+6 -3
View File
@@ -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
+2 -2
View File
@@ -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);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 20 KiB