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 零问题。
351 lines
11 KiB
Dart
351 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../agent.dart';
|
|
import '../setup/autostart.dart';
|
|
import '../setup/first_run.dart';
|
|
import 'routes.dart';
|
|
import 'setup_wizard.dart';
|
|
|
|
/// 应用外壳:左侧插件菜单 + 右侧插件页面(菜单项来自路由注册表)。
|
|
class AppShell extends StatefulWidget {
|
|
const AppShell({super.key, required this.agent, this.startError});
|
|
|
|
final Agent agent;
|
|
final String? startError;
|
|
|
|
@override
|
|
State<AppShell> createState() => _AppShellState();
|
|
}
|
|
|
|
class _AppShellState extends State<AppShell> {
|
|
int _selectedIndex = 0;
|
|
bool _autostart = false;
|
|
bool _autostartLoaded = false;
|
|
|
|
Agent get _agent => widget.agent;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadAutostart();
|
|
_maybeShowWizard();
|
|
}
|
|
|
|
Future<void> _loadAutostart() async {
|
|
final enabled = await Autostart.isEnabled();
|
|
if (mounted) {
|
|
setState(() {
|
|
_autostart = enabled;
|
|
_autostartLoaded = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleAutostart(bool value) async {
|
|
setState(() => _autostart = value);
|
|
try {
|
|
value ? await Autostart.enable() : await Autostart.disable();
|
|
} catch (_) {
|
|
if (mounted) await _loadAutostart(); // 失败回读真实状态
|
|
}
|
|
}
|
|
|
|
Future<void> _maybeShowWizard() async {
|
|
if (await FirstRun.isDone()) return;
|
|
if (!mounted) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => SetupWizard(agent: _agent, onFinished: _loadAutostart),
|
|
);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Scaffold(
|
|
body: Row(
|
|
children: [
|
|
_sidebar(context, scheme),
|
|
VerticalDivider(width: 1, thickness: 1, color: scheme.outlineVariant),
|
|
Expanded(child: _content(context, scheme)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------- 左侧菜单 ----------------
|
|
|
|
Widget _sidebar(BuildContext context, ColorScheme scheme) {
|
|
final serviceOnline = _agent.port != null;
|
|
return Container(
|
|
width: 216,
|
|
color: scheme.surfaceContainerLow,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// 产品区
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 18, 16, 14),
|
|
child: Row(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Image.asset('assets/logo.png', width: 28, height: 28),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'资安信插件助手',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleSmall
|
|
?.copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.2),
|
|
),
|
|
Text('桌面插件', style: Theme.of(context).textTheme.bodySmall),
|
|
],
|
|
),
|
|
),
|
|
_ServiceDot(online: serviceOnline),
|
|
],
|
|
),
|
|
),
|
|
Divider(height: 1, thickness: 1, color: scheme.outlineVariant),
|
|
|
|
// 插件菜单(来自路由注册表)
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 6),
|
|
child: Text(
|
|
'插件',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.labelSmall
|
|
?.copyWith(color: scheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
for (var i = 0; i < appRoutes.length; i++)
|
|
_SidebarItem(
|
|
route: appRoutes[i],
|
|
selected: i == _selectedIndex,
|
|
onTap: () => setState(() => _selectedIndex = i),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
Divider(height: 1, thickness: 1, color: scheme.outlineVariant),
|
|
|
|
// 设置区:开机自启 + 重新运行引导
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
|
child: SwitchListTile(
|
|
dense: true,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
|
value: _autostartLoaded && _autostart,
|
|
onChanged: Autostart.isSupported ? _toggleAutostart : null,
|
|
title: Text('开机自动启动', style: Theme.of(context).textTheme.bodySmall),
|
|
secondary: Icon(Icons.power_settings_new,
|
|
size: 18, color: scheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 8, 4),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton.icon(
|
|
onPressed: () {
|
|
showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => SetupWizard(agent: _agent, onFinished: _loadAutostart),
|
|
);
|
|
},
|
|
icon: const Icon(Icons.restart_alt, size: 14),
|
|
label: const Text('重新运行引导'),
|
|
style: TextButton.styleFrom(
|
|
textStyle: Theme.of(context).textTheme.bodySmall,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
|
child: Text(
|
|
'v1.0.0',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall
|
|
?.copyWith(color: scheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------- 右侧内容 ----------------
|
|
|
|
Widget _content(BuildContext context, ColorScheme scheme) {
|
|
final error = widget.startError;
|
|
if (error != null) {
|
|
return _ServiceErrorView(message: error);
|
|
}
|
|
// IndexedStack 保持各插件页状态,切换菜单不丢页面状态
|
|
return IndexedStack(
|
|
index: _selectedIndex,
|
|
children: [
|
|
for (final route in appRoutes)
|
|
route.builder(context, _agent),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 侧栏插件项:图标 + 标题/副标题 + 选中态(高亮底 + 左侧指示条)。
|
|
class _SidebarItem extends StatelessWidget {
|
|
const _SidebarItem({required this.route, required this.selected, required this.onTap});
|
|
|
|
final AppRoute route;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
static const double _minHeight = 46;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final fg = selected ? scheme.onSurface : scheme.onSurfaceVariant;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 2),
|
|
child: Material(
|
|
color: selected ? scheme.primaryContainer : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
hoverColor: scheme.primaryContainer.withValues(alpha: 0.35),
|
|
onTap: onTap,
|
|
child: Container(
|
|
constraints: const BoxConstraints(minHeight: _minHeight),
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 3,
|
|
height: 22,
|
|
decoration: BoxDecoration(
|
|
color: selected ? scheme.primary : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Icon(route.icon, size: 20, color: selected ? scheme.primary : fg),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
route.title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodyMedium
|
|
?.copyWith(color: fg, fontWeight: selected ? FontWeight.w600 : null),
|
|
),
|
|
Text(
|
|
route.subtitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall
|
|
?.copyWith(color: scheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 服务在线小圆点(带可达性语义,不只靠颜色)。
|
|
class _ServiceDot extends StatelessWidget {
|
|
const _ServiceDot({required this.online});
|
|
|
|
final bool online;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Tooltip(
|
|
message: online ? '本机服务运行中' : '本机服务未运行',
|
|
child: Icon(
|
|
online ? Icons.check_circle : Icons.cancel,
|
|
size: 16,
|
|
color: online ? scheme.primary : scheme.error,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 服务启动失败整页提示(引导重试/看日志)。
|
|
class _ServiceErrorView extends StatelessWidget {
|
|
const _ServiceErrorView({required this.message});
|
|
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 460),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.error_outline_rounded, color: scheme.error, size: 32),
|
|
const SizedBox(width: 12),
|
|
Text('本机服务启动失败', style: Theme.of(context).textTheme.titleLarge),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
message,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodyMedium
|
|
?.copyWith(color: scheme.onSurfaceVariant),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'常见原因:多个助手实例同时运行占用了端口。关闭其他实例后重新打开本应用即可。',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|