Files
desktop-plugin/lib/ui/setup_wizard.dart
T
dzc 78d6b82305 fix: 向导第 2 步自动轮询——手动装驱动/插打印机后页面自动刷新
用户实测反馈:手动安装驱动输密码后向导页面无变化。排查确认驱动实际已装上
(/Library/Printers/HiTi 存在),问题是向导只在按钮事件时刷新——用户在 App
外装完驱动没有任何事件触发重建,页面停在"尚未安装"旧文案。

修复:向导停留在第 2 步期间每 3 秒静默轮询(驱动目录判断 + 打印机状态),
装完驱动/插上打印机/系统识别出队列后页面自动切换分支;驱动未装分支同时
补"重新检测"按钮;一键安装执行中暂停轮询避免打断。
2026-09-21 14:09:13 +08:00

399 lines
14 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;
Agent get _agent => widget.agent;
@override
void initState() {
super.initState();
_checkPrinter();
// 第 2 步期间自动轮询:用户手动装驱动 / 插上打印机后页面自动刷新
// (驱动目录判断零成本;getStatus 走 lpoptions,3s 一次可接受)
_pollTimer = Timer.periodic(const Duration(seconds: 3), (_) {
if (_step == 1 && mounted && !_driverInstalling) {
_checkPrinter(silent: true);
}
});
}
Timer? _pollTimer;
@override
void dispose() {
_pollTimer?.cancel();
super.dispose();
}
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:打印机检测 ----------------
/// 一键安装内置驱动(mac):装完自动重检(队列要等打印机连上/系统识别后才出现)。
Future<void> _installDriver() async {
final dmg = DriverInstall.bundledDmgPath();
if (dmg == null) return;
setState(() {
_driverInstalling = true;
_driverInstallError = null;
});
try {
await DriverInstall.installFromDmg(dmg, onLog: _agent.log.write);
await _checkPrinter();
} catch (e) {
if (mounted) setState(() => _driverInstallError = '$e');
} finally {
if (mounted) setState(() => _driverInstalling = false);
}
}
Widget _stepPrinter(ColorScheme scheme) {
final s = _printerStatus;
final ready = s != null && s.online;
final driverMissing = Platform.isMacOS && !DriverInstall.isInstalled();
final bundledDmg = Platform.isMacOS ? DriverInstall.bundledDmgPath() : null;
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:驱动未装 → 首选一键安装内置驱动,其次官网链接
if (driverMissing) ...[
Text(
'本机尚未安装 HiTi 打印机驱动(mac 打印必需的系统组件)。',
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6),
),
const SizedBox(height: 8),
Row(
children: [
FilledButton.tonalIcon(
onPressed: _driverInstalling || bundledDmg == 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 (bundledDmg == 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(
'安装完成后页面会自动刷新(也可手动重新检测);随后 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);③ 回到这里重新检测。'
: 'Windows 打印站:打开 HiTi 官网下载 CS-2xx Windows 驱动并安装,USB 连接打印机;本应用也支持免驱动的 SDK 直连模式,装完驱动或接好打印机后点「重新检测」。',
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => launchUrl(Uri.parse(_driverUrl)),
icon: const Icon(Icons.open_in_new, size: 14),
label: const Text('打开 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('完成'),
),
],
);
}