diff --git a/scripts/test_queue_dart.py b/scripts/test_queue_dart.py index 215ed2e..ec470d8 100644 --- a/scripts/test_queue_dart.py +++ b/scripts/test_queue_dart.py @@ -19,10 +19,11 @@ def main(): harness = Path(folder) (harness/'test').mkdir() ui = ' material_ui: 1.5.0\n cupertino_ui: 1.1.1\n' if args.widgets else '' - (harness/'pubspec.yaml').write_text("name: queue_protocol_checks\nenvironment:\n sdk: '>=3.11.0 <4.0.0'\ndependencies:\n flutter:\n sdk: flutter\n"+ui+"dev_dependencies:\n flutter_test:\n sdk: flutter\n") + (harness/'pubspec.yaml').write_text("name: queue_protocol_checks\nenvironment:\n sdk: '>=3.11.0 <4.0.0'\ndependencies:\n flutter:\n sdk: flutter\n"+ui+"dev_dependencies:\n flutter_test:\n sdk: flutter\nflutter:\n uses-material-design: true\n") names = ['queue_protocol_test.dart', 'queue_session_test.dart', 'queue_presentation_test.dart'] if args.widgets: names.append('queue_resource_widget_test.dart') + names.append('queue_workspace_widget_test.dart') for name in names: source = (ROOT/'serverbox/test/unit/app'/name).read_text() (harness/'test'/name).write_text(source) diff --git a/serverbox/lib/core/gpu_queue/presentation.dart b/serverbox/lib/core/gpu_queue/presentation.dart index 8e6120a..4073426 100644 --- a/serverbox/lib/core/gpu_queue/presentation.dart +++ b/serverbox/lib/core/gpu_queue/presentation.dart @@ -3,6 +3,26 @@ import 'package:server_box/core/gpu_queue/protocol.dart'; /// Human-readable queue data. Missing values stay missing, and historical GPU /// readings never become live metrics merely because an SSH session is open. abstract final class QueuePresentation { + static String taskStatus(Object? state) => + const { + 'ready': '待启动', + 'queued': '队列中', + 'waiting_gpu': '等待 GPU', + 'checking_resources': '核查资源', + 'dispatching': '正在投放', + 'starting': '启动中', + 'running': '运行中', + 'stopping': '正在停止', + 'stopped': '已停止', + 'cancelled': '已取消', + 'completed': '已完成', + 'failed': '失败', + 'blocked': '需核查', + 'execution_unknown': '执行状态待核查', + 'interrupted': '已中断', + }[state] ?? + '状态待核查'; + static num? _number(Object? value) => value is num && value.isFinite && value >= 0 ? value : null; static String _decimal(num value) => value == value.roundToDouble() diff --git a/serverbox/lib/core/utils/gpu_queue_theme.dart b/serverbox/lib/core/utils/gpu_queue_theme.dart index d918644..9fe857b 100644 --- a/serverbox/lib/core/utils/gpu_queue_theme.dart +++ b/serverbox/lib/core/utils/gpu_queue_theme.dart @@ -15,29 +15,139 @@ ThemeData gpuQueueTheme(ThemeData base) { onSurfaceVariant: Color(dark ? 0xFFBEB7AA : 0xFF69655E), primary: Color(dark ? 0xFFE0A387 : 0xFF8B543E), onPrimary: Color(dark ? 0xFF302018 : 0xFFFFFFFF), + primaryContainer: Color(dark ? 0xFF49392F : 0xFFF0E7DB), + onPrimaryContainer: Color(dark ? 0xFFF1D7C8 : 0xFF714832), + secondary: Color(dark ? 0xFF9DC8AD : 0xFF38664D), + onSecondary: Color(dark ? 0xFF153524 : 0xFFFFFFFF), + secondaryContainer: Color(dark ? 0xFF293D32 : 0xFFE7EEE3), + onSecondaryContainer: Color(dark ? 0xFFBBDDC6 : 0xFF315D45), + surfaceContainerLowest: Color(dark ? 0xFF211F1C : 0xFFFDFCF9), + surfaceContainerLow: Color(dark ? 0xFF302D28 : 0xFFFDFCF9), + surfaceContainer: Color(dark ? 0xFF35312B : 0xFFEEECE4), + surfaceContainerHigh: Color(dark ? 0xFF3B372F : 0xFFE8E4DA), + surfaceContainerHighest: Color(dark ? 0xFF444036 : 0xFFDFDAD0), + outline: Color(dark ? 0xFF8F877B : 0xFF8C8579), + outlineVariant: Color(dark ? 0xFF4A453D : 0xFFE2DED5), ); final text = base.textTheme.apply( bodyColor: scheme.onSurface, displayColor: scheme.onSurface, ); TextStyle? heading(TextStyle? style) => style?.copyWith( - fontFamily: 'Georgia', - fontFamilyFallback: const ['Songti SC', 'Noto Serif CJK SC'], + fontFamily: base.platform == TargetPlatform.linux + ? 'DejaVu Serif' + : 'Georgia', + fontFamilyFallback: base.platform == TargetPlatform.linux + ? const ['WenQuanYi Micro Hei', 'Noto Serif CJK SC'] + : const [ + 'Songti SC', + 'Noto Serif CJK SC', + 'DejaVu Serif', + 'WenQuanYi Micro Hei', + ], fontWeight: FontWeight.w500, ); return base.copyWith( colorScheme: scheme, + canvasColor: scheme.surface, scaffoldBackgroundColor: scheme.surface, cardColor: Color(dark ? 0xFF302D28 : 0xFFFDFCF9), + dividerColor: scheme.outlineVariant, textTheme: text.copyWith( headlineLarge: heading(text.headlineLarge), headlineMedium: heading(text.headlineMedium), headlineSmall: heading(text.headlineSmall), titleLarge: heading(text.titleLarge), + titleMedium: text.titleMedium?.copyWith( + fontSize: 15, + fontWeight: FontWeight.w600, + height: 1.5, + ), + bodyLarge: text.bodyLarge?.copyWith(fontSize: 15, height: 1.7), + bodyMedium: text.bodyMedium?.copyWith(fontSize: 14, height: 1.6), + bodySmall: text.bodySmall?.copyWith( + fontSize: 12, + height: 1.6, + color: scheme.onSurfaceVariant, + ), ), appBarTheme: base.appBarTheme.copyWith( backgroundColor: scheme.surface, foregroundColor: scheme.onSurface, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + titleTextStyle: text.titleMedium?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + cardTheme: CardThemeData( + elevation: 0, + color: scheme.surfaceContainerLow, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: scheme.outlineVariant), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size(44, 44), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(44, 44), + foregroundColor: scheme.onSurface, + backgroundColor: scheme.surfaceContainerLow, + side: BorderSide(color: scheme.outlineVariant), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + inputDecorationTheme: base.inputDecorationTheme.copyWith( + filled: true, + fillColor: scheme.surfaceContainerLow, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: scheme.primary, width: 2), + ), + ), + chipTheme: base.chipTheme.copyWith( + backgroundColor: scheme.surfaceContainerLow, + selectedColor: scheme.secondaryContainer, + side: BorderSide(color: scheme.outlineVariant), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + listTileTheme: base.listTileTheme.copyWith( + selectedColor: scheme.primary, + selectedTileColor: scheme.primaryContainer, + ), + navigationRailTheme: base.navigationRailTheme.copyWith( + backgroundColor: scheme.surface, + indicatorColor: scheme.primaryContainer, + ), + navigationBarTheme: base.navigationBarTheme.copyWith( + backgroundColor: scheme.surface, + indicatorColor: scheme.primaryContainer, + surfaceTintColor: Colors.transparent, + ), + dialogTheme: base.dialogTheme.copyWith( + backgroundColor: scheme.surfaceContainerLow, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), ); } diff --git a/serverbox/lib/view/page/gpu_queue/page.dart b/serverbox/lib/view/page/gpu_queue/page.dart index 2fd0cde..e07773b 100644 --- a/serverbox/lib/view/page/gpu_queue/page.dart +++ b/serverbox/lib/view/page/gpu_queue/page.dart @@ -13,6 +13,7 @@ import 'package:server_box/data/provider/server/single.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/ssh/terminal_source.dart'; import 'package:server_box/view/page/gpu_queue/resource.dart'; +import 'package:server_box/view/page/gpu_queue/workspace.dart'; import 'package:server_box/view/page/server/detail/view.dart'; import 'package:server_box/view/page/ssh/page/page.dart'; import 'package:server_box/view/page/storage/sftp.dart'; @@ -130,8 +131,9 @@ class _GpuQueuePageState extends ConsumerState void _message(Object text) { if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$text'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$text'))); } } @@ -209,26 +211,6 @@ class _GpuQueuePageState extends ConsumerState 'failed', 'interrupted', ]; - static const _labels = { - 'ready': '待启动', - 'queued': '队列中', - 'waiting_gpu': '等待 GPU', - 'checking_resources': '核查资源', - 'dispatching': '正在投放', - 'starting': '启动中', - 'running': '运行中', - 'stopping': '正在停止', - 'stopped': '已停止', - 'cancelled': '已取消', - 'completed': '已完成', - 'failed': '失败', - 'blocked': '需核查', - 'execution_unknown': '执行状态待核查', - 'interrupted': '已中断', - }; - String _status(Map task) => - _labels[task['state']] ?? '${task['state']}'; - @override Widget build(BuildContext context) { super.build(context); @@ -311,6 +293,18 @@ class _GpuQueuePageState extends ConsumerState _target(service, task, servers)?.id != widget.serverFilter) { return false; } + if (_filter == 'queue' && + !const [ + 'queued', + 'waiting_gpu', + 'checking_resources', + 'dispatching', + 'running', + 'starting', + 'stopping', + ].contains(task['state'])) { + return false; + } if (_filter == 'ready' && task['state'] != 'ready') return false; if (_filter == 'history' && !_ended.contains(task['state'])) { return false; @@ -350,18 +344,30 @@ class _GpuQueuePageState extends ConsumerState runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ - Text( - 'SSH · ${ssh != null && !ssh.isClosed ? '可用' : '未连接'}', + QueueConnectionIndicator( + available: ssh != null && !ssh.isClosed, + label: + 'SSH · ${ssh != null && !ssh.isClosed ? '可用' : '未连接'}', ), - Text( - '任务服务 · ${queue.loading - ? '同步中' - : connected - ? '可用' - : '不可用'}', + QueueConnectionIndicator( + available: connected, + label: + '任务服务 · ${queue.loading + ? '同步中' + : connected + ? '可用' + : '不可用'}', ), - Text( - 'GPU 采样 · ${queue.resources.any((r) => QueueTarget.fresh(r, DateTime.now(), connected: connected)) ? '实时' : '暂无实时读数'}', + QueueConnectionIndicator( + available: queue.resources.any( + (r) => QueueTarget.fresh( + r, + DateTime.now(), + connected: connected, + ), + ), + label: + 'GPU 采样 · ${queue.resources.any((r) => QueueTarget.fresh(r, DateTime.now(), connected: connected)) ? '实时' : '暂无实时读数'}', ), OutlinedButton( onPressed: queue.loading || queue.acting @@ -387,6 +393,7 @@ class _GpuQueuePageState extends ConsumerState queue.auto ? '仅监看已授权队列;新任务仍需切回 Edit 并点击开始。' : '提交、连接与切换模式均不会授权任务。点击开始才会执行这一版。', + style: Theme.of(context).textTheme.bodySmall, ), ), if (queue.error != null) @@ -414,139 +421,89 @@ class _GpuQueuePageState extends ConsumerState ), if (queue.loading) const LinearProgressIndicator(), Expanded( - child: LayoutBuilder( - builder: (context, bounds) { - final wide = bounds.maxWidth >= 850; - final list = Column( - children: [ - if (widget.overview) - Padding( - padding: const EdgeInsets.all(20), - child: Text( - '每个实验,都有进展。\n${queue.tasks.where((t) => t['state'] == 'ready').length} 个待启动 · ${queue.tasks.where((t) => t['state'] == 'running').length} 个运行中', - style: Theme.of(context).textTheme.headlineSmall - ?.copyWith( - fontFamily: 'Georgia', - fontFamilyFallback: const [ - 'Songti SC', - 'Noto Serif CJK SC', - ], - ), + child: QueueWorkspaceView( + tasks: tasks, + allTasks: widget.serverFilter == null + ? queue.tasks + : queue.tasks + .where( + (t) => + _target(service, t, servers)?.id == + widget.serverFilter, + ) + .toList(), + selected: _selected, + overview: widget.overview, + loading: queue.loading, + controller: _listScroll, + onQuery: (value) => setState(() => _query = value), + filter: _filter, + onFilter: (value) => setState(() => _filter = value), + onSelect: (task, wide) { + setState(() => _selected = task['id']); + if (!wide) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _MobileDetail( + queue: queue, + taskId: task['id'], + buildDetail: (t) => _detail( + service, + queue, + t, + servers, + mobile: true, ), ), - Padding( - padding: const EdgeInsets.all(12), - child: TextField( - decoration: const InputDecoration( - labelText: '搜索任务', - prefixIcon: Icon(Icons.search), - ), - onChanged: (value) => - setState(() => _query = value), - ), - ), - Wrap( - spacing: 8, - children: [ - for (final entry in { - 'all': '全部', - 'ready': '待启动', - 'history': '已结束', - }.entries) - ChoiceChip( - label: Text(entry.value), - selected: _filter == entry.key, - onSelected: (_) => - setState(() => _filter = entry.key), - ), - ], ), - Expanded( - child: tasks.isEmpty - ? Center( - child: Text( - queue.loading - ? '正在加载…' - : widget.serverFilter != null - ? '暂无已明确关联此服务器的任务。\n请在任务详情关联实例。' - : '暂无匹配任务。\n通过 gpuq submit 提交后在这里点击开始。', - ), - ) - : ListView.builder( - controller: _listScroll, - itemCount: tasks.length, - itemBuilder: (context, index) { - final task = tasks[index]; - return ListTile( - key: ValueKey(task['id']), - selected: _selected == task['id'], - title: Text('${task['name']}'), - subtitle: Text( - '${_status(task)} · ${task['project'] ?? ''}\n${task['id']}', - ), - isThreeLine: true, - trailing: const Icon(Icons.chevron_right), - onTap: () { - setState(() => _selected = task['id']); - if (!wide) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => _MobileDetail( - queue: queue, - taskId: task['id'], - buildDetail: (t) => _detail( - service, - queue, - t, - servers, - mobile: true, - ), - ), - ), - ); - } - }, - ); - }, + ); + } + }, + detail: task == null + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.article_outlined, + size: 32, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), - ), - ExpansionTile( - title: const Text('服务器与 GPU · 展开监控'), - children: [ - SizedBox( - height: 180, - child: ListView( - children: [ - if (queue.resources.isEmpty) - const ListTile(title: Text('尚无 GPU 实例')), - for (final resource in queue.resources) - QueueResourceView( - resource: resource, - connected: connected, - ), - ], - ), + const SizedBox(height: 12), + Text( + '从一个任务开始', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + '选择左侧任务,查看资源需求、步骤与结果。', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), - ], - ), + ), + ) + : _detail(service, queue, task, servers), + monitor: QueuePanel( + child: ExpansionTile( + title: const Text('服务器与 GPU'), + subtitle: const Text('按需展开监控'), + children: [ + if (queue.resources.isEmpty) + const ListTile(title: Text('尚无 GPU 实例')), + for (final resource in queue.resources) + QueueResourceView( + resource: resource, + connected: connected, + ), ], - ); - return wide - ? Row( - children: [ - SizedBox(width: 360, child: list), - const VerticalDivider(width: 1), - Expanded( - child: task == null - ? const Center( - child: Text('选择一个任务,查看步骤与结果。'), - ) - : _detail(service, queue, task, servers), - ), - ], - ) - : list; - }, + ), + ), ), ), ], @@ -578,22 +535,7 @@ class _GpuQueuePageState extends ConsumerState controller: mobile ? null : _detailScroll, padding: const EdgeInsets.all(24), children: [ - Text(_status(task), style: Theme.of(context).textTheme.labelLarge), - Text( - '${task['name']}', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontFamily: 'Georgia', - fontFamilyFallback: const ['Songti SC', 'Noto Serif CJK SC'], - ), - ), - SelectableText( - '${task['description'] ?? ''}\n${task['id']}\n授权:${task['approved_at'] ?? '等待点击开始'}', - ), - const SizedBox(height: 16), - Text('申请资源需求', style: Theme.of(context).textTheme.titleMedium), - SelectableText(QueuePresentation.requirements(task)), - SelectableText('固定版本:${QueuePresentation.version(task)}'), - const SizedBox(height: 16), + QueueTaskHeading(task: task), Wrap( spacing: 8, runSpacing: 8, diff --git a/serverbox/lib/view/page/gpu_queue/resource.dart b/serverbox/lib/view/page/gpu_queue/resource.dart index 4c32b3d..b3879f2 100644 --- a/serverbox/lib/view/page/gpu_queue/resource.dart +++ b/serverbox/lib/view/page/gpu_queue/resource.dart @@ -19,8 +19,14 @@ class QueueResourceView extends StatelessWidget { final now = DateTime.now(); final fresh = QueueTarget.fresh(resource, now, connected: connected); final gpus = QueuePresentation.gpus(resource, now, connected: connected); - return Padding( - padding: const EdgeInsets.all(12), + return Container( + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(10), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/serverbox/lib/view/page/gpu_queue/workspace.dart b/serverbox/lib/view/page/gpu_queue/workspace.dart new file mode 100644 index 0000000..adfe9a8 --- /dev/null +++ b/serverbox/lib/view/page/gpu_queue/workspace.dart @@ -0,0 +1,516 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:server_box/core/gpu_queue/presentation.dart'; + +/// Native surfaces shared by the live workspace and its CPU render checks. +class QueuePanel extends StatelessWidget { + const QueuePanel({ + super.key, + required this.child, + this.padding = EdgeInsets.zero, + }); + final Widget child; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Material( + color: colors.surfaceContainerLow, + shape: RoundedRectangleBorder( + side: BorderSide(color: colors.outlineVariant), + borderRadius: BorderRadius.circular(12), + ), + child: Padding(padding: padding, child: child), + ); + } +} + +class QueueStatusBadge extends StatelessWidget { + const QueueStatusBadge({super.key, required this.state}); + final String state; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final active = ['running', 'completed'].contains(state); + final error = [ + 'failed', + 'interrupted', + 'blocked', + 'execution_unknown', + ].contains(state); + final color = error + ? colors.error + : active + ? colors.secondary + : colors.onSurfaceVariant; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: error + ? colors.errorContainer + : active + ? colors.secondaryContainer + : colors.surfaceContainer, + borderRadius: BorderRadius.circular(5), + ), + child: Text( + QueuePresentation.taskStatus(state), + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: color), + ), + ); + } +} + +class QueueConnectionIndicator extends StatelessWidget { + const QueueConnectionIndicator({ + super.key, + required this.label, + required this.available, + }); + final String label; + final bool available; + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + available ? Icons.check_circle_outline : Icons.circle_outlined, + size: 14, + color: available ? colors.secondary : colors.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text(label, style: Theme.of(context).textTheme.bodySmall), + ], + ); + } +} + +class QueueWorkspaceHeading extends StatelessWidget { + const QueueWorkspaceHeading({super.key}); + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'GPU QUEUE / 个人计算工作台', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + letterSpacing: 1.5, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + '每个实验,都有进展。', + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(fontSize: 28, height: 1.35), + ), + const SizedBox(height: 6), + Text('查看准备情况,安排顺序,再开始。', style: Theme.of(context).textTheme.bodySmall), + ], + ), + ); +} + +class QueueStats extends StatelessWidget { + const QueueStats({super.key, required this.tasks, this.compact = false}); + final List> tasks; + final bool compact; + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final entries = [ + ('待你启动', ['ready'], Icons.schedule_outlined, colors.onSurface), + ( + '执行队列', + ['queued', 'waiting_gpu', 'checking_resources', 'dispatching'], + Icons.format_list_numbered, + colors.onSurface, + ), + ( + '正在运行', + ['running', 'starting', 'stopping'], + Icons.play_circle_outline, + colors.secondary, + ), + ( + '需要处理', + ['failed', 'interrupted', 'blocked', 'execution_unknown'], + Icons.error_outline, + colors.error, + ), + ]; + return LayoutBuilder( + builder: (context, bounds) { + final columns = bounds.maxWidth >= 600 ? 4 : 2; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final (label, states, icon, color) in entries) + SizedBox( + width: (bounds.maxWidth - (columns - 1) * 12) / columns, + child: QueuePanel( + padding: EdgeInsets.all(compact ? 12 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Icon(icon, size: 18, color: color), + ], + ), + const SizedBox(height: 8), + Text( + tasks + .where((task) => states.contains(task['state'])) + .length + .toString() + .padLeft(2, '0'), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontFamily: null, + fontSize: compact ? 25 : 30, + color: color, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} + +class QueueTaskTile extends StatelessWidget { + const QueueTaskTile({ + super.key, + required this.task, + required this.selected, + required this.onTap, + }); + final Map task; + final bool selected; + final VoidCallback onTap; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context), colors = theme.colorScheme; + final id = '${task['id'] ?? ''}'; + final steps = task['steps'] as List? ?? []; + final stage = (task['stage'] is num ? (task['stage'] as num).toInt() : 0) + .clamp(0, steps.length); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: selected ? colors.primaryContainer : colors.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: selected ? colors.primary : colors.outlineVariant, + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + '${task['name'] ?? '未命名任务'}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium, + ), + ), + const SizedBox(width: 12), + QueueStatusBadge(state: '${task['state']}'), + ], + ), + const SizedBox(height: 6), + Text( + '${task['project'] ?? '未指定项目'} · ${id.length > 14 ? '…${id.substring(id.length - 10)}' : id}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + if (steps.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + steps.join(' → '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.secondary, + ), + ), + ], + if (task['state'] == 'running' && steps.isNotEmpty) ...[ + const SizedBox(height: 12), + LinearProgressIndicator( + value: stage / steps.length, + minHeight: 3, + color: colors.secondary, + backgroundColor: colors.secondaryContainer, + ), + ], + const SizedBox(height: 10), + Row( + children: [ + if (task['agent'] != null) + Text( + '${task['agent']}', + style: theme.textTheme.labelSmall, + ), + const Spacer(), + Text( + '查看详情', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.primary, + ), + ), + const SizedBox(width: 4), + Icon(Icons.arrow_forward, size: 16, color: colors.primary), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Keeps task scrolling independent of the detail on desktop; on small/short +/// windows the heading, counts and tasks share one scrollable column. +class QueueWorkspaceView extends StatelessWidget { + const QueueWorkspaceView({ + super.key, + required this.tasks, + required this.allTasks, + required this.onSelect, + required this.onQuery, + required this.filter, + required this.onFilter, + required this.controller, + required this.detail, + required this.monitor, + this.selected, + this.overview = false, + this.loading = false, + }); + final List> tasks, allTasks; + final String? selected; + final bool overview, loading; + final String filter; + final void Function(Map task, bool wide) onSelect; + final ValueChanged onQuery, onFilter; + final ScrollController controller; + final Widget detail, monitor; + + Widget _filters(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: const ValueKey('queue-search'), + decoration: const InputDecoration( + hintText: '搜索任务、项目…', + prefixIcon: Icon(Icons.search), + ), + onChanged: onQuery, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + for (final entry in { + 'all': '全部任务', + 'queue': '执行队列', + 'ready': '待启动', + 'history': '已结束', + }.entries) + ChoiceChip( + label: Text(entry.value), + selected: filter == entry.key, + onSelected: (_) => onFilter(entry.key), + ), + ], + ), + const SizedBox(height: 16), + ], + ); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, bounds) { + final wide = bounds.maxWidth >= 960 && bounds.maxHeight >= 520; + final space = bounds.maxWidth < 600 ? 16.0 : 24.0; + Widget list({required bool intro}) => CustomScrollView( + controller: controller, + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (intro) ...[ + if (overview) const QueueWorkspaceHeading(), + QueueStats(tasks: allTasks, compact: true), + const SizedBox(height: 20), + ], + _filters(context), + ], + ), + ), + if (tasks.isEmpty) + SliverToBoxAdapter( + child: QueuePanel( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Icon( + loading ? Icons.sync : Icons.inbox_outlined, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + loading ? '正在同步任务…' : '暂无匹配任务', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + allTasks.isEmpty + ? '新提交的任务会出现在这里,准备好后由你点击开始。' + : '试试其他筛选条件或搜索词。', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + SliverList.builder( + itemCount: tasks.length, + findChildIndexCallback: (key) { + final index = tasks.indexWhere( + (task) => ValueKey(task['id']) == key, + ); + return index < 0 ? null : index; + }, + itemBuilder: (context, index) { + final task = tasks[index]; + return QueueTaskTile( + key: ValueKey(task['id']), + task: task, + selected: selected == task['id'], + onTap: () => onSelect(task, wide), + ); + }, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 12, bottom: 24), + child: monitor, + ), + ), + ], + ); + return Padding( + padding: EdgeInsets.fromLTRB(space, 16, space, 0), + child: wide + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (overview && bounds.maxHeight >= 740) + const QueueWorkspaceHeading(), + QueueStats(tasks: allTasks, compact: bounds.maxHeight < 740), + const SizedBox(height: 20), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: (bounds.maxWidth * .46).clamp(360.0, 540.0), + child: list(intro: false), + ), + const SizedBox(width: 20), + Expanded(child: QueuePanel(child: detail)), + ], + ), + ), + ], + ) + : list(intro: true), + ); + }, + ); +} + +class QueueTaskHeading extends StatelessWidget { + const QueueTaskHeading({super.key, required this.task}); + final Map task; + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + QueueStatusBadge(state: '${task['state']}'), + const SizedBox(height: 12), + Text( + '${task['name']}', + style: Theme.of(context).textTheme.headlineSmall?.copyWith(height: 1.4), + ), + const SizedBox(height: 8), + Text( + '${task['description'] ?? ''}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + SelectableText( + '${task['id']}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 20), + QueuePanel( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('申请资源需求', style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 6), + SelectableText(QueuePresentation.requirements(task)), + const SizedBox(height: 8), + SelectableText( + '固定版本:${QueuePresentation.version(task)}', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + task['approved_at'] == null ? '等待你点击开始' : '这一版已授权', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ); +} diff --git a/serverbox/test/unit/app/queue_workspace_widget_test.dart b/serverbox/test/unit/app/queue_workspace_widget_test.dart new file mode 100644 index 0000000..2ed2faf --- /dev/null +++ b/serverbox/test/unit/app/queue_workspace_widget_test.dart @@ -0,0 +1,218 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:server_box/core/utils/gpu_queue_theme.dart'; +import 'package:server_box/view/page/gpu_queue/workspace.dart'; + +void main() { + final tasks = >[ + { + 'id': 'GQ-fixture00000001', + 'name': '视觉模型 · 第一阶段训练', + 'project': '视觉实验', + 'state': 'running', + 'agent': 'Codex', + 'steps': ['训练', '评测', '导出'], + 'stage': 1, + }, + { + 'id': 'GQ-fixture00000002', + 'name': '文本检索 · 验证集评测', + 'project': '检索实验', + 'state': 'queued', + 'agent': 'Claude', + 'steps': ['准备', '评测', '汇总'], + }, + { + 'id': 'GQ-fixture00000003', + 'name': '多模态长标题与中文 English 混排的任务,用于验证窄屏换行与状态按钮', + 'description': '代码与配置已固定,准备好后由你决定何时开始。', + 'project': '多模态实验', + 'state': 'ready', + 'agent': 'Codex', + 'steps': ['训练', '评测', '推理'], + 'resources': {'gpus': 1, 'memory_gb': 24}, + 'version': 'fixture-a1b2c3', + }, + ]; + + setUpAll(() async { + // Optional Linux fonts for review artifacts; production loads no test fonts. + for (final entry in { + 'WenQuanYi Micro Hei': Platform.environment['GPU_QUEUE_TEST_CJK_FONT'], + 'DejaVu Serif': Platform.environment['GPU_QUEUE_TEST_SERIF_FONT'], + 'Roboto': Platform.environment['GPU_QUEUE_TEST_ROBOTO_FONT'], + 'MaterialIcons': Platform.environment['GPU_QUEUE_TEST_ICON_FONT'], + }.entries) { + if (entry.value == null) continue; + final loader = FontLoader(entry.key) + ..addFont( + File( + entry.value!, + ).readAsBytes().then((bytes) => ByteData.sublistView(bytes)), + ); + await loader.load(); + } + }); + + Future capture(WidgetTester tester, GlobalKey key, String name) async { + final dir = Platform.environment['GPU_QUEUE_EVIDENCE_DIR']; + if (dir == null) return; + // Real production widgets rendered by the Flutter CPU engine, not a Mac app capture. + final image = + await (key.currentContext!.findRenderObject() as RenderRepaintBoundary) + .toImage(pixelRatio: 1); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await File('$dir/$name.png').writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + } + + testWidgets( + 'native workspace reflows at small, short, wide and enlarged text sizes in both themes', + (tester) async { + addTearDown(() => tester.binding.setSurfaceSize(null)); + for (final brightness in [Brightness.light, Brightness.dark]) { + for (final (size, scale) in [ + (const Size(390, 844), 1.0), + (const Size(320, 640), 1.5), + (const Size(1440, 1000), 1.0), + (const Size(1100, 500), 1.0), + (const Size(1100, 800), 2.0), + ]) { + await tester.binding.setSurfaceSize(size); + final scroll = ScrollController(), captureKey = GlobalKey(); + var selections = 0; + final base = ThemeData( + platform: TargetPlatform.linux, + brightness: brightness, + fontFamily: Platform.environment['GPU_QUEUE_TEST_CJK_FONT'] == null + ? null + : 'WenQuanYi Micro Hei', + ); + await tester.pumpWidget( + MaterialApp( + theme: gpuQueueTheme(base), + home: MediaQuery( + data: MediaQueryData( + size: size, + textScaler: TextScaler.linear(scale), + ), + child: RepaintBoundary( + key: captureKey, + child: Scaffold( + appBar: AppBar(title: const Text('GPU Queue · 原生组件预览')), + body: QueueWorkspaceView( + tasks: tasks, + allTasks: tasks, + selected: tasks.last['id'], + overview: true, + controller: scroll, + onSelect: (_, _) => selections++, + onQuery: (_) {}, + filter: 'all', + onFilter: (_) {}, + detail: ListView( + padding: const EdgeInsets.all(24), + children: [ + QueueTaskHeading(task: tasks.last), + FilledButton( + onPressed: () {}, + child: const Text('开始这一版任务'), + ), + const SizedBox(height: 24), + Text( + '执行步骤与日志', + style: gpuQueueTheme(base).textTheme.titleLarge, + ), + for (final step in ['训练', '评测', '推理']) + ListTile( + leading: const Icon(Icons.circle_outlined), + title: Text(step), + subtitle: const Text('等待执行'), + ), + ], + ), + monitor: const ExpansionTile( + title: Text('服务器与 GPU'), + subtitle: Text('按需展开监控'), + children: [Text('示例数据 · 非实时')], + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + tester.takeException(), + isNull, + reason: '$brightness $size $scale', + ); + if (scale == 1 && size.width != 1100) { + await tester.runAsync( + () => capture( + tester, + captureKey, + 'native-${size.width.toInt()}-${brightness.name}', + ), + ); + } + await tester.scrollUntilVisible( + find.text('视觉模型 · 第一阶段训练'), + 180, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible(find.text('视觉模型 · 第一阶段训练')); + await tester.pumpAndSettle(); + await tester.tap(find.text('视觉模型 · 第一阶段训练')); + await tester.pumpAndSettle(); + expect(selections, 1); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + scroll.dispose(); + } + } + }, + ); + + testWidgets( + 'task tile remains keyboard operable without a direct start action', + (tester) async { + var selected = false; + var task = tasks.last; + late StateSetter refresh; + await tester.pumpWidget( + MaterialApp( + theme: gpuQueueTheme(ThemeData()), + home: StatefulBuilder( + builder: (context, setState) { + refresh = setState; + return Scaffold( + body: QueueTaskTile( + key: ValueKey(task['id']), + task: task, + selected: false, + onTap: () => selected = true, + ), + ); + }, + ), + ), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + final focused = FocusManager.instance.primaryFocus; + refresh(() => task = {...task, 'state': 'queued'}); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(focused)); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(selected, isTrue); + expect(find.text('开始这一版任务'), findsNothing); + }, + ); +}