feat: 后台托盘 + 资安信品牌化 + 修复布局断言(Phase 4 部分)
- 后台托盘(mac/Windows):window_manager 拦截关窗改隐藏,服务持续运行; 托盘单击恢复窗口、右键菜单(显示主界面/退出),退出入口仅放托盘菜单 - 品牌:App 更名"资安信插件助手"(mac PRODUCT_NAME/xib、win main.cpp+Runner.rc(code_page 65001)、 MaterialApp/侧栏/托盘 tooltip/日志);图标全家桶换资安信盾牌 logo (tool/gen_app_icons.dart 生成 mac AppIcon 7 尺寸/win ICO 7 尺寸/托盘白底圆盘版/assets logo) - 修复启动布局断言:SliverList 内 Row(stretch) 需 IntrinsicHeight 包裹, 否则子项收到 h=Infinity 约束(用户实测报错,test/ui_smoke_test.dart 守护复现) - Agent.stop 幂等(托盘退出与窗口销毁竞态安全) - 测试修复:smoke 测试改 tester.runAsync 包真实 socket I/O(fake async zone 里会永久挂起)、 显式 pump 替代 pumpAndSettle;13 个测试全过,analyze 零问题,release 构建实测通过
This commit is contained in:
1 parent
0a78ff1b58
commit
bee734fd5b
35 files changed
+1554
-196
No files matched your search
@@ -0,0 +1,271 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../agent.dart';
|
||||
import 'routes.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;
|
||||
|
||||
Agent get _agent => widget.agent;
|
||||
|
||||
@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(16, 10, 16, 12),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user