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 构建实测通过
No files matched your search
@@ -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 旁
|
||||
|
||||
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 719 B |
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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";
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 520 B After Width: | Height: | Size: 286 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 666 B |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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}');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 20 KiB |