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:
1 parent
0a78ff1b58
commit
bee734fd5b
35 files changed
+1554
-196
No files matched your search
+6
-1
@@ -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
@@ -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),
|
||||
),
|
||||
];
|
||||
Reference in new issue
Block a user