Files
desktop-plugin/lib/ui/setup_wizard.dart
T
dzc d1c34cb833 fix(向导): Windows 第 2 步"已连接"误报——online 不等于设备可用
Pavo 后端的 online 只要枚举到打印机就为 true(与 C# 版语义一致),
status 可能同时是 0x80 离线;向导 ready 原判定只看 online,掩盖了
"打印机已断开连接或已关机"的真实状态(管理端如实显示,App 却说已连接,
用户实测被误导)。改为 status 为就绪/打印中才算通过,离线等状态走
badText 如实显示 statusText。
2026-09-21 16:58:16 +08:00

428 lines
15 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 'package:url_launcher/url_launcher.dart';
import '../agent.dart';
import '../server/protocol.dart';
import '../setup/autostart.dart';
import '../setup/driver_install.dart';
import '../setup/first_run.dart';
import '../setup/url_scheme.dart';
/// 首次引导向导:服务自检 → 打印机检测(含驱动引导)→ 自启/唤起配置。
/// 深色主题对话框,三步 Stepper 风格;允许无打印机完成(纯联调机)。
class SetupWizard extends StatefulWidget {
const SetupWizard({super.key, required this.agent, required this.onFinished});
final Agent agent;
final VoidCallback onFinished;
/// 便捷入口:未完成过向导时弹出。
static Future<void> showIfNeeded(BuildContext context, Agent agent) async {
if (await FirstRun.isDone()) return;
if (!context.mounted) return;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => SetupWizard(
agent: agent,
onFinished: () {},
),
);
}
@override
State<SetupWizard> createState() => _SetupWizardState();
}
class _SetupWizardState extends State<SetupWizard> {
static const _driverUrl = 'https://www.hiti.com/support/download.aspx?MenuID=72&lang=CN';
int _step = 0;
bool _autostart = true;
bool _autostartBusy = false;
bool _printerChecking = false;
bool _driverInstalling = false;
String? _driverInstallError;
PrinterStatus? _printerStatus;
/// 驱动是否已装(null=查询中;mac=驱动目录,win=注册表安装标记)
bool? _driverInstalled;
Agent get _agent => widget.agent;
@override
void initState() {
super.initState();
_checkPrinter();
_queryDriver();
// 第 2 步期间自动轮询:用户手动装驱动 / 插上打印机后页面自动刷新
// (驱动目录判断零成本;getStatus 走 lpoptions,3s 一次可接受)
_pollTimer = Timer.periodic(const Duration(seconds: 3), (_) {
if (_step == 1 && mounted && !_driverInstalling) {
_checkPrinter(silent: true);
_queryDriver();
}
});
}
Timer? _pollTimer;
@override
void dispose() {
_pollTimer?.cancel();
super.dispose();
}
Future<void> _queryDriver() async {
final installed = await DriverInstall.queryInstalled();
if (mounted) setState(() => _driverInstalled = installed);
}
Future<void> _checkPrinter({bool silent = false}) async {
if (!silent) setState(() => _printerChecking = true);
try {
final status = await _agent.backend.getStatus();
if (mounted) setState(() => _printerStatus = status);
} catch (_) {
if (mounted) setState(() => _printerStatus = null);
} finally {
if (mounted && !silent) setState(() => _printerChecking = false);
}
}
Future<void> _finish() async {
setState(() => _autostartBusy = true);
try {
if (_autostart && Autostart.isSupported) {
await Autostart.enable();
}
await UrlScheme.ensureRegistered();
await FirstRun.complete();
} finally {
if (mounted) setState(() => _autostartBusy = false);
}
if (mounted) {
Navigator.of(context).pop();
widget.onFinished();
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Dialog(
backgroundColor: scheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: scheme.outlineVariant),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(scheme),
const SizedBox(height: 18),
if (_step == 0) _stepWelcome(scheme),
if (_step == 1) _stepPrinter(scheme),
if (_step == 2) _stepFinish(scheme),
const SizedBox(height: 20),
_footer(scheme),
],
),
),
),
);
}
Widget _header(ColorScheme scheme) => Row(
children: [
Icon(Icons.extension_rounded, color: scheme.primary, size: 26),
const SizedBox(width: 10),
Text('初次使用引导', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
_stepBadge(scheme),
],
);
Widget _stepBadge(ColorScheme scheme) => Row(
children: [
for (var i = 0; i < 3; i++) ...[
Container(
width: i == _step ? 18 : 8,
height: 8,
decoration: BoxDecoration(
color: i <= _step ? scheme.primary : scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
),
if (i < 2) const SizedBox(width: 6),
],
],
);
// ---------------- Step 0:服务自检 ----------------
Widget _stepWelcome(ColorScheme scheme) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('资安信插件助手在本机为管理端网页提供打印等桌面能力,安装完成后保持后台运行即可,无需日常操作。',
style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 14),
_checkRow(
scheme,
ok: _agent.port != null,
okText: '本机服务运行中${_agent.port != null ? "(127.0.0.1:${_agent.port})" : ""}',
badText: '本机服务未启动(可能是端口被占用,请重启应用)',
),
if (Autostart.legacyCsharpAgentInstalled) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'检测到旧版打印助手(C# 版)在本机自启。两者协议兼容可并存,'
'如需完全切换到本应用,可在终端执行:\n'
'launchctl unload ~/Library/LaunchAgents/com.zax.cardprinteragent.plist',
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.5),
),
),
],
],
);
// ---------------- Step 1:打印机检测 ----------------
/// 一键安装内置驱动:装完自动重检(队列要等打印机连上/系统识别后才出现)。
Future<void> _installDriver() async {
final installer = DriverInstall.bundledInstallerPath();
if (installer == null) return;
setState(() {
_driverInstalling = true;
_driverInstallError = null;
});
try {
await DriverInstall.install(installer, onLog: _agent.log.write);
await _queryDriver();
await _checkPrinter();
} catch (e) {
if (mounted) setState(() => _driverInstallError = '$e');
} finally {
if (mounted) setState(() => _driverInstalling = false);
}
}
Widget _stepPrinter(ColorScheme scheme) {
final s = _printerStatus;
// 就绪/打印中才算通过:Windows 后端的 online=只要枚举到打印机就 true,
// 不代表设备可用(status 可能是 0x80 离线)——必须看 statusText 如实显示
final ready = s != null &&
s.online &&
(s.status == 0 || s.status == 0x00000002 || s.status == 0x00000005 || s.status == 0x00000006);
final driverMissing = _driverInstalled == false;
final bundledInstaller = DriverInstall.bundledInstallerPath();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_checkRow(
scheme,
ok: ready,
loading: _printerChecking,
okText: '打印机就绪${s != null && s.model.isNotEmpty ? "(${s.model})" : ""}',
badText: s == null ? '未检测到打印队列' : (s.statusText.isEmpty ? '打印机未就绪' : s.statusText),
),
const SizedBox(height: 12),
// 驱动未装 → 首选一键安装内置驱动,其次官网链接(mac/win 通用)
if (driverMissing) ...[
Text(
Platform.isMacOS
? '本机尚未安装 HiTi 打印机驱动(mac 打印必需的系统组件)。'
: '本机尚未安装 HiTi 打印机驱动。直打通道需要驱动建立的 USB 连接,装好即可自动识别打印机。',
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6),
),
const SizedBox(height: 8),
Row(
children: [
FilledButton.tonalIcon(
onPressed: _driverInstalling || bundledInstaller == null ? null : _installDriver,
icon: _driverInstalling
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download, size: 16),
label: Text(_driverInstalling
? '正在安装…(请在系统弹窗中确认)'
: '一键安装驱动(内置安装包)'),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () => launchUrl(Uri.parse(_driverUrl)),
icon: const Icon(Icons.open_in_new, size: 14),
label: const Text('改为手动下载'),
),
],
),
if (bundledInstaller == null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'(当前构建未内置驱动安装包,请用官网链接手动安装)',
style: Theme.of(context).textTheme.bodySmall,
),
),
if (_driverInstallError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'安装失败:$_driverInstallError',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: scheme.error),
),
),
const SizedBox(height: 8),
Text(
Platform.isMacOS
? '安装完成后:USB 连接打印机并开机,系统识别出现打印队列即完成。'
: '安装完成后 USB 连接打印机并开机,回到这里重新检测即完成。',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),
TextButton.icon(
onPressed: _printerChecking ? null : () => _checkPrinter(),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('重新检测'),
),
]
// 驱动已装但打印机未就绪 → 引导连接/添加队列
else if (!ready) ...[
Text(
Platform.isMacOS
? '驱动已安装。请确认:① 打印机 USB 已连接并开机;② 系统设置 → 打印机与扫描仪 里出现 HiTi 打印机(没有的话点下方「+」添加,选 HiTi CS-200e);③ 回到这里重新检测。'
: '请先:① 用 USB 线连接打印机并开机,点「重新检测」——本应用通过 SDK 直连打印机,通常无需安装驱动;② 若反复检测不到打印机,再考虑打开 HiTi 官网安装 CS-2xx Windows 驱动(可选兜底)。',
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6),
),
const SizedBox(height: 8),
if (!Platform.isMacOS)
Align(
alignment: Alignment.centerLeft,
child: Text(
'驱动下载(可选兜底)',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => launchUrl(Uri.parse(_driverUrl)),
icon: const Icon(Icons.open_in_new, size: 14),
label: Text(Platform.isMacOS ? '打开 HiTi 驱动下载页' : 'HiTi 驱动下载页(可选)'),
),
),
const SizedBox(height: 4),
TextButton.icon(
onPressed: _printerChecking ? null : _checkPrinter,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('重新检测'),
),
] else ...[
Text('打印机已连接,可以正常接收管理端提交的卡面打印。',
style: Theme.of(context).textTheme.bodySmall),
],
],
);
}
// ---------------- Step 2:完成配置 ----------------
Widget _stepFinish(ColorScheme scheme) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _autostart,
onChanged: Autostart.isSupported
? (v) => setState(() => _autostart = v)
: null,
title: const Text('开机自动启动'),
subtitle: Text(
Autostart.isSupported ? '登录系统后自动在后台运行(可在左侧菜单底部随时修改)' : '当前平台不支持',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 6),
_checkRow(
scheme,
ok: true,
okText: '网页唤起支持(zaxprint:// 协议)已启用',
badText: '',
),
const SizedBox(height: 8),
Text(
'全部完成。关闭窗口将最小化到托盘,服务持续运行;退出请通过托盘菜单。',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
// ---------------- 公共组件 ----------------
Widget _checkRow(ColorScheme scheme,
{required bool ok, required String okText, required String badText, bool loading = false}) {
final color = ok ? scheme.primary : const Color(0xFFD97706);
return Row(
children: [
if (loading)
const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
else
Icon(ok ? Icons.check_circle : Icons.info_outline, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
ok ? okText : badText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: color),
),
),
],
);
}
Widget _footer(ColorScheme scheme) => Row(
children: [
if (_step > 0)
TextButton(
onPressed: () => setState(() => _step--),
child: const Text('上一步'),
),
const Spacer(),
if (_step < 2)
FilledButton(
onPressed: () => setState(() => _step++),
child: Text(_step == 0 ? '下一步' : '跳过,下一步'),
)
else
FilledButton(
onPressed: _autostartBusy ? null : _finish,
child: const Text('完成'),
),
],
);
}