Add bulk archive task selection

This commit is contained in:
Haitao Pan 2026-05-28 09:17:52 +08:00
parent b22bdd396e
commit 3b12fd532b
2 changed files with 402 additions and 17 deletions

View File

@ -4,7 +4,7 @@ import '../../i18n/app_language.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
class SettingsArchivedTasksPanel extends StatelessWidget {
class SettingsArchivedTasksPanel extends StatefulWidget {
const SettingsArchivedTasksPanel({
super.key,
required this.sessions,
@ -16,6 +16,84 @@ class SettingsArchivedTasksPanel extends StatelessWidget {
final Future<void> Function(String sessionKey) onRestore;
final Future<void> Function(String sessionKey) onDelete;
@override
State<SettingsArchivedTasksPanel> createState() =>
_SettingsArchivedTasksPanelState();
}
class _SettingsArchivedTasksPanelState
extends State<SettingsArchivedTasksPanel> {
final Set<String> _selectedSessionKeys = <String>{};
List<GatewaySessionSummary> get _selectedSessions => widget.sessions
.where((session) => _selectedSessionKeys.contains(session.key))
.toList(growable: false);
bool get _hasSelection => _selectedSessionKeys.isNotEmpty;
bool get _allSelected =>
widget.sessions.isNotEmpty &&
_selectedSessionKeys.length == widget.sessions.length;
@override
void didUpdateWidget(covariant SettingsArchivedTasksPanel oldWidget) {
super.didUpdateWidget(oldWidget);
final availableKeys = widget.sessions.map((session) => session.key).toSet();
_selectedSessionKeys.removeWhere((key) => !availableKeys.contains(key));
}
void _toggleSessionSelection(String sessionKey, bool selected) {
setState(() {
if (selected) {
_selectedSessionKeys.add(sessionKey);
} else {
_selectedSessionKeys.remove(sessionKey);
}
});
}
void _toggleAllSelection() {
setState(() {
if (_allSelected) {
_selectedSessionKeys.clear();
} else {
_selectedSessionKeys
..clear()
..addAll(widget.sessions.map((session) => session.key));
}
});
}
Future<void> _restoreSelectedSessions() async {
final selected = _selectedSessions;
if (selected.isEmpty) {
return;
}
for (final session in selected) {
await widget.onRestore(session.key);
}
if (mounted) {
setState(_selectedSessionKeys.clear);
}
}
Future<void> _deleteSelectedSessions(BuildContext context) async {
final selected = _selectedSessions;
if (selected.isEmpty) {
return;
}
final confirmed = await _confirmBulkDelete(context, selected.length);
if (!confirmed) {
return;
}
for (final session in selected) {
await widget.onDelete(session.key);
}
if (mounted) {
setState(_selectedSessionKeys.clear);
}
}
@override
Widget build(BuildContext context) {
final palette = context.palette;
@ -37,7 +115,10 @@ class SettingsArchivedTasksPanel extends StatelessWidget {
),
),
Text(
appText('${sessions.length}', '${sessions.length} items'),
appText(
'${widget.sessions.length}',
'${widget.sessions.length} items',
),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
fontWeight: FontWeight.w600,
@ -46,23 +127,39 @@ class SettingsArchivedTasksPanel extends StatelessWidget {
],
),
const SizedBox(height: 12),
if (sessions.isEmpty)
if (widget.sessions.isEmpty)
_ArchivedTasksEmptyState()
else
Column(
children: [
for (final session in sessions) ...[
_ArchivedTasksBulkToolbar(
allSelected: _allSelected,
selectedCount: _selectedSessionKeys.length,
totalCount: widget.sessions.length,
onToggleAll: _toggleAllSelection,
onRestoreSelected: _hasSelection
? () => _restoreSelectedSessions()
: null,
onDeleteSelected: _hasSelection
? () => _deleteSelectedSessions(context)
: null,
),
const SizedBox(height: 8),
for (final session in widget.sessions) ...[
_ArchivedTaskTile(
session: session,
onRestore: () => onRestore(session.key),
selected: _selectedSessionKeys.contains(session.key),
onSelectionChanged: (selected) =>
_toggleSessionSelection(session.key, selected),
onRestore: () => widget.onRestore(session.key),
onDelete: () async {
final confirmed = await _confirmDelete(context, session);
if (confirmed) {
await onDelete(session.key);
await widget.onDelete(session.key);
}
},
),
if (session != sessions.last)
if (session != widget.sessions.last)
Divider(height: 1, color: palette.strokeSoft),
],
],
@ -117,20 +214,154 @@ class SettingsArchivedTasksPanel extends StatelessWidget {
final palette = context.palette;
final result = await showDialog<bool>(
context: context,
builder: (context) =>
_DeleteYesConfirmationDialog(session: session, palette: palette),
builder: (context) => _DeleteYesConfirmationDialog(
message: appText(
'此操作会删除「${session.label}」的归档记录和任务目录。请输入 Yes 继续。',
'This deletes "${session.label}" archived records and task directory. Type Yes to continue.',
),
palette: palette,
),
);
return result ?? false;
}
Future<bool> _confirmBulkDelete(
BuildContext context,
int selectedCount,
) async {
final palette = context.palette;
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(appText('批量彻底删除归档记录', 'Delete archived records')),
content: Text(
appText(
'将从 XWorkmate 中删除已选择的 $selectedCount 条归档任务记录、消息状态和本地线程工作目录。此操作不可撤销。',
'This removes $selectedCount selected archived tasks from XWorkmate task records, message state, and local thread workspaces. This cannot be undone.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(appText('取消', 'Cancel')),
),
FilledButton.icon(
key: const ValueKey('settings-archived-task-confirm-bulk-delete'),
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: palette.danger,
foregroundColor: Colors.white,
),
icon: const Icon(Icons.delete_sweep_outlined),
label: Text(appText('批量删除', 'Delete selected')),
),
],
),
);
if (result != true || !context.mounted) {
return false;
}
final yesResult = await showDialog<bool>(
context: context,
builder: (context) => _DeleteYesConfirmationDialog(
message: appText(
'此操作会删除已选择的 $selectedCount 条归档记录和任务目录。请输入 Yes 继续。',
'This deletes $selectedCount selected archived records and task directories. Type Yes to continue.',
),
palette: palette,
),
);
return yesResult ?? false;
}
}
class _ArchivedTasksBulkToolbar extends StatelessWidget {
const _ArchivedTasksBulkToolbar({
required this.allSelected,
required this.selectedCount,
required this.totalCount,
required this.onToggleAll,
required this.onRestoreSelected,
required this.onDeleteSelected,
});
final bool allSelected;
final int selectedCount;
final int totalCount;
final VoidCallback onToggleAll;
final Future<void> Function()? onRestoreSelected;
final Future<void> Function()? onDeleteSelected;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
children: [
Checkbox(
key: const ValueKey('settings-archived-tasks-select-all'),
value: allSelected,
onChanged: (_) => onToggleAll(),
),
TextButton(
key: const ValueKey('settings-archived-tasks-select-all-button'),
onPressed: onToggleAll,
child: Text(appText('全选', 'Select all')),
),
const SizedBox(width: 8),
Expanded(
child: Text(
appText(
selectedCount == 0
? '$totalCount 条归档任务'
: '已选择 $selectedCount / $totalCount',
selectedCount == 0
? '$totalCount archived tasks'
: '$selectedCount / $totalCount selected',
),
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
key: const ValueKey('settings-archived-tasks-bulk-restore'),
onPressed: onRestoreSelected,
icon: const Icon(Icons.unarchive_outlined),
label: Text(appText('批量解除归档', 'Restore selected')),
),
const SizedBox(width: 8),
IconButton(
key: const ValueKey('settings-archived-tasks-bulk-delete'),
tooltip: appText('批量彻底删除归档记录', 'Delete selected records'),
onPressed: onDeleteSelected == null
? null
: () async => onDeleteSelected!(),
icon: const Icon(Icons.delete_sweep_outlined),
),
],
),
);
}
}
class _DeleteYesConfirmationDialog extends StatefulWidget {
const _DeleteYesConfirmationDialog({
required this.session,
required this.message,
required this.palette,
});
final GatewaySessionSummary session;
final String message;
final AppPalette palette;
@override
@ -158,12 +389,7 @@ class _DeleteYesConfirmationDialogState
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
appText(
'此操作会删除「${widget.session.label}」的归档记录和任务目录。请输入 Yes 继续。',
'This deletes "${widget.session.label}" archived records and task directory. Type Yes to continue.',
),
),
Text(widget.message),
const SizedBox(height: 14),
TextField(
key: const ValueKey('settings-archived-task-delete-yes-input'),
@ -231,11 +457,15 @@ class _ArchivedTasksEmptyState extends StatelessWidget {
class _ArchivedTaskTile extends StatelessWidget {
const _ArchivedTaskTile({
required this.session,
required this.selected,
required this.onSelectionChanged,
required this.onRestore,
required this.onDelete,
});
final GatewaySessionSummary session;
final bool selected;
final ValueChanged<bool> onSelectionChanged;
final Future<void> Function() onRestore;
final Future<void> Function() onDelete;
@ -249,6 +479,14 @@ class _ArchivedTaskTile extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
key: ValueKey<String>(
'settings-archived-task-select-${session.key}',
),
value: selected,
onChanged: (value) => onSelectionChanged(value ?? false),
),
const SizedBox(width: 8),
Container(
width: 34,
height: 34,

View File

@ -114,8 +114,155 @@ void main() {
expect(calls, contains('delete:draft:archived-task'));
});
testWidgets('selects all archived tasks and restores selected records', (
tester,
) async {
final calls = <String>[];
await tester.pumpWidget(
_buildTestApp(
child: SettingsArchivedTasksPanel(
sessions: const <GatewaySessionSummary>[
_firstArchivedTask,
_secondArchivedTask,
],
onRestore: (sessionKey) async {
calls.add('restore:$sessionKey');
},
onDelete: (sessionKey) async {
calls.add('delete:$sessionKey');
},
),
),
);
expect(find.text('共 2 条归档任务'), findsOneWidget);
await tester.tap(
find.byKey(const ValueKey('settings-archived-tasks-select-all')),
);
await tester.pumpAndSettle();
expect(find.text('已选择 2 / 2 条'), findsOneWidget);
await tester.tap(
find.byKey(const ValueKey('settings-archived-tasks-bulk-restore')),
);
await tester.pumpAndSettle();
expect(calls, <String>[
'restore:draft:archived-task',
'restore:draft:second-archived-task',
]);
expect(find.text('共 2 条归档任务'), findsOneWidget);
});
testWidgets('deletes selected archived tasks after bulk confirmation', (
tester,
) async {
final calls = <String>[];
await tester.pumpWidget(
_buildTestApp(
child: SettingsArchivedTasksPanel(
sessions: const <GatewaySessionSummary>[
_firstArchivedTask,
_secondArchivedTask,
],
onRestore: (sessionKey) async {
calls.add('restore:$sessionKey');
},
onDelete: (sessionKey) async {
calls.add('delete:$sessionKey');
},
),
),
);
await tester.tap(
find.byKey(
const ValueKey(
'settings-archived-task-select-draft:second-archived-task',
),
),
);
await tester.pumpAndSettle();
expect(find.text('已选择 1 / 2 条'), findsOneWidget);
await tester.tap(
find.byKey(const ValueKey('settings-archived-tasks-bulk-delete')),
);
await tester.pumpAndSettle();
expect(find.text('批量彻底删除归档记录'), findsOneWidget);
await tester.tap(
find.byKey(const ValueKey('settings-archived-task-confirm-bulk-delete')),
);
await tester.pumpAndSettle();
expect(find.text('确认彻底删除'), findsOneWidget);
expect(calls, isEmpty);
await tester.enterText(
find.byKey(const ValueKey('settings-archived-task-delete-yes-input')),
'Yes',
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('settings-archived-task-confirm-delete-yes')),
);
await tester.pumpAndSettle();
expect(calls, <String>['delete:draft:second-archived-task']);
expect(find.text('共 2 条归档任务'), findsOneWidget);
});
}
const _firstArchivedTask = GatewaySessionSummary(
key: 'draft:archived-task',
kind: 'assistant',
displayName: '导出 PDF',
surface: 'Assistant',
subject: null,
room: null,
space: null,
updatedAtMs: 1779178980000,
sessionId: 'draft:archived-task',
systemSent: false,
abortedLastRun: false,
thinkingLevel: null,
verboseLevel: null,
inputTokens: null,
outputTokens: null,
totalTokens: null,
model: null,
contextTokens: null,
derivedTitle: '导出 PDF',
lastMessagePreview: '输出为PDF文件',
);
const _secondArchivedTask = GatewaySessionSummary(
key: 'draft:second-archived-task',
kind: 'assistant',
displayName: '整理会议纪要',
surface: 'Assistant',
subject: null,
room: null,
space: null,
updatedAtMs: 1779179000000,
sessionId: 'draft:second-archived-task',
systemSent: false,
abortedLastRun: false,
thinkingLevel: null,
verboseLevel: null,
inputTokens: null,
outputTokens: null,
totalTokens: null,
model: null,
contextTokens: null,
derivedTitle: '整理会议纪要',
lastMessagePreview: '会议纪要已保存',
);
Widget _buildTestApp({required Widget child}) {
return MaterialApp(
theme: AppTheme.light(),