Files
desktop-plugin/lib/ui/card_printer_page.dart
2026-09-23 18:45:57 +08:00

603 lines
21 KiB
Dart
Raw Permalink 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 '../build_info.g.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),
),
const SizedBox(height: 2),
Text(
'版本 $kBuildVersion',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(color: scheme.onSurfaceVariant.withValues(alpha: 0.7)),
),
],
),
),
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;
}