603 lines
21 KiB
Dart
603 lines
21 KiB
Dart
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;
|
||
}
|