Files
desktop-plugin/lib/ui/card_printer_page.dart
T
dzc 726210edcb feat: 本地日志文件系统 + 修复双击打不开/向导完成无反应
用户实测两个问题(先装好驱动的机器):
1. 双击 App 图标不一定能打开——根因 single_instance:唤醒端口 38210 被
   残留占用(上次异常退出/TIME_WAIT)时,connect 无应答仍返回 false →
   main 直接 exit(0)。修复:端口被占但无应答判定为残留占用,照常启动
   (仅唤醒功能降级,FileLog 留痕);有应答才作为第二实例退出。
2. 向导第三步点"完成"没反应——根因 _finish 只有 finally 没有 catch:
   Autostart/UrlScheme(reg 三连)/FirstRun 任一抛异常被静默吞,Navigator.pop
   永不执行=对话框锁死。修复:每步独立 try-catch + 分步日志,成败都关闭
   向导,失败汇总 SnackBar 提示"详见运行日志"。

本地日志文件系统(用户要求:仔细记录、可发回分析、定时删除):
- lib/utils/file_log.dart:按天滚动 app-YYYYMMDD.log、单文件 5MB 轮转加
  序号、启动清理 7 天前旧文件(按文件名日期判定);全同步追加写(低频
  日志,保证异常退出前落盘);目录 mac ~/Library/Logs/zax-desktop-plugin/
  win %APPDATA%\zax-desktop-plugin\logs\(不引第三方依赖)
- AgentLog.write 挂文件输出(一处改动覆盖全部 22 个现有调用点);
  main.dart FileLog.init 提到最前,bootWarning/startError 全部落日志,
  启动横幅记录平台/exe 路径/参数
- pavo isolate 的 [pavo-diag] 诊断日志传日志路径(纯数据,isolate 安全)
  直接写文件——GUI 模式下 isolate stdout 本来就丢
- 插件页技术详情加"打开日志文件夹"按钮(Explorer/Finder 直达,用户打包
  app-*.log 发回分析)

19 个测试全过、analyze 零问题。
2026-09-22 15:09:24 +08:00

593 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import '../agent.dart';
import '../server/protocol.dart';
import '../utils/file_log.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();
}
/// 资源管理器/Finder 打开日志目录(用户取 app-*.log 发回分析)。
Future<void> _openLogFolder() async {
final dir = FileLog.dir;
if (dir == null) return;
try {
if (Platform.isWindows) {
await Process.start('explorer', [dir], mode: ProcessStartMode.detached);
} else if (Platform.isMacOS) {
await Process.start('open', [dir], mode: ProcessStartMode.detached);
}
} catch (e) {
_agent.log.write('打开日志文件夹失败:$e(目录:$dir)');
}
}
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),
Row(
children: [
Expanded(
child: Text('运行日志(最近 50 条)',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
)),
),
if (FileLog.dir != null)
TextButton.icon(
onPressed: _openLogFolder,
icon: const Icon(Icons.folder_open, size: 14),
label: const Text('打开日志文件夹'),
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.bodySmall,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
),
),
],
),
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;
}