Files
desktop-plugin/lib/ui/setup_wizard.dart
T
dzc 3ecac9a302 feat: Phase 4 收尾 + Phase 5 打包(首启向导/开机自启/URL scheme 闭环/mac dmg)
Phase 4 剩余三项:
- 首次引导向导(setup/first_run + ui/setup_wizard):服务自检(含 C# 助手共存
  提示)→ 打印机检测与 HiTi 驱动安装指引(官网链接/重新检测,mac 与 win 文案
  分支,允许无打印机完成)→ 开机自启开关 + scheme 注册;侧栏底部常驻设置区
  (自启开关 + 重新运行引导)
- 开机自启(setup/autostart):mac 写 LaunchAgent(RunAtLoad 一次性,不用
  KeepAlive——托盘退出不能被拉回);Windows 走 HKCU Run(reg 命令免 win32 依赖)
- URL scheme 闭环:单实例唤醒端口 38210(setup/single_instance)——zaxprint://
  唤起或重复双击启动第二进程时通知常驻实例弹窗后自退,跨平台一致(弃用
  app_links 的 mac 事件转发不确定性);Windows 启动时幂等注册 HKCU scheme;
  前端 cardPrinter.ts 加 launchAgent()(zaxprint://open),开卡抽屉离线时显示
  "启动打印助手"按钮,点击唤起后 1s×6 轮询自动上线(biz 前端改动待用户提交)

Phase 5:
- tool/package_macos.sh:构建 → ad-hoc 就地签名(--deep,勿加 --options=runtime:
  hardened runtime 库校验会因 ad-hoc 无 Team ID 拒载 framework,实测踩坑)→
  hdiutil dmg;实测产出 22MB dmg + 签名后冒烟通过
- windows/installer.iss:Inno Setup 安装包脚本(快捷方式/桌面图标/开机自启
  Tasks/zaxprint scheme 注册与卸载清理/HiTi SDK DLL 随 Release 目录打包)

依赖 +url_launcher +shared_preferences;13 测试全过、analyze 零问题。
2026-09-21 13:51:42 +08:00

297 lines
10 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: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/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;
PrinterStatus? _printerStatus;
Agent get _agent => widget.agent;
@override
void initState() {
super.initState();
_checkPrinter();
}
Future<void> _checkPrinter() async {
setState(() => _printerChecking = true);
try {
final status = await _agent.backend.getStatus();
if (mounted) setState(() => _printerStatus = status);
} catch (_) {
if (mounted) setState(() => _printerStatus = null);
} finally {
if (mounted) 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:打印机检测 ----------------
Widget _stepPrinter(ColorScheme scheme) {
final s = _printerStatus;
final ready = s != null && s.online;
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),
),
if (!ready) ...[
const SizedBox(height: 12),
Text(
Platform.isMacOS
? '安装步骤:① 打开 HiTi 官网下载 macOS 驱动(HiTiCardTypeMacDrivers dmg),双击安装里面的 pkg;② USB 连接打印机并开机;③ 系统设置 → 打印机与扫描仪 确认出现 HiTi 打印机;④ 回到这里点「重新检测」。'
: '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 ...[
const SizedBox(height: 12),
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('完成'),
),
],
);
}