Files
desktop-plugin/lib/ui/setup_wizard.dart
T
dzc e88c9dcfa2 fix(向导): Windows 文案改为"SDK 直连免驱动优先,官方驱动仅可选兜底"
用户在 Windows 上误读引导为"需要装驱动"。实际 Windows 走 PavoApi SDK 直连
USB 打印(DLL 已随 App 打包),大概率无需官方驱动;原文案把"去官网下载驱动"
放在第一位造成误导。改为:① 连 USB 开机重新检测(主路径);② 反复检测不到
再考虑官方驱动(可选兜底,链接标注"可选")。mac 文案不变。
2026-09-21 16:08:14 +08:00

409 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;
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);③ 回到这里重新检测。'
: '请先:① 用 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('完成'),
),
],
);
}