2322 lines
73 KiB
Dart
2322 lines
73 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||
|
||
import '../core/storage/storage_manager.dart';
|
||
import '../models/cloud_file.dart';
|
||
import '../models/batch_rename.dart';
|
||
import '../models/fast_transfer.dart';
|
||
import '../models/media_library.dart';
|
||
import '../providers/auth_provider.dart';
|
||
import '../providers/file_provider.dart';
|
||
import 'media_library_page.dart';
|
||
|
||
enum WorkspaceTool { scan, rename, fastTransfer, tmdb, categories }
|
||
|
||
extension WorkspaceToolDetails on WorkspaceTool {
|
||
String get title {
|
||
switch (this) {
|
||
case WorkspaceTool.scan:
|
||
return '文件扫描与清理';
|
||
case WorkspaceTool.rename:
|
||
return '批量重命名';
|
||
case WorkspaceTool.fastTransfer:
|
||
return '秒传工具';
|
||
case WorkspaceTool.tmdb:
|
||
return 'TMDB 整理';
|
||
case WorkspaceTool.categories:
|
||
return '分类管理';
|
||
}
|
||
}
|
||
|
||
IconData get icon {
|
||
switch (this) {
|
||
case WorkspaceTool.scan:
|
||
return Icons.manage_search_rounded;
|
||
case WorkspaceTool.rename:
|
||
return Icons.text_fields_rounded;
|
||
case WorkspaceTool.fastTransfer:
|
||
return Icons.bolt_rounded;
|
||
case WorkspaceTool.tmdb:
|
||
return Icons.auto_fix_high_rounded;
|
||
case WorkspaceTool.categories:
|
||
return Icons.grid_view_rounded;
|
||
}
|
||
}
|
||
}
|
||
|
||
class WorkspaceToolsPage extends StatelessWidget {
|
||
final WorkspaceTool tool;
|
||
final VoidCallback onClose;
|
||
|
||
const WorkspaceToolsPage({
|
||
super.key,
|
||
required this.tool,
|
||
required this.onClose,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final body = switch (tool) {
|
||
WorkspaceTool.scan => const _FileScanTool(),
|
||
WorkspaceTool.rename => const _BatchRenameTool(),
|
||
WorkspaceTool.fastTransfer => const _FastTransferTool(),
|
||
WorkspaceTool.tmdb => const MediaLibraryPage(showLibrarySidebar: true),
|
||
WorkspaceTool.categories => const _CategoryManagementTool(),
|
||
};
|
||
return Column(
|
||
children: [
|
||
_ToolHeader(tool: tool, onClose: onClose),
|
||
Expanded(child: body),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ToolHeader extends StatelessWidget {
|
||
final WorkspaceTool tool;
|
||
final VoidCallback onClose;
|
||
|
||
const _ToolHeader({required this.tool, required this.onClose});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Container(
|
||
height: 58,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
decoration: BoxDecoration(
|
||
border: Border(bottom: BorderSide(color: cs.border)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
ShadButton.ghost(
|
||
onPressed: onClose,
|
||
leading: const Icon(Icons.arrow_back_rounded, size: 16),
|
||
child: const Text('返回'),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Icon(tool.icon, size: 20, color: cs.primary),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
tool.title,
|
||
style: TextStyle(
|
||
color: cs.foreground,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FileScanTool extends ConsumerStatefulWidget {
|
||
const _FileScanTool();
|
||
|
||
@override
|
||
ConsumerState<_FileScanTool> createState() => _FileScanToolState();
|
||
}
|
||
|
||
class _FileScanToolState extends ConsumerState<_FileScanTool> {
|
||
bool _scanning = false;
|
||
bool _cancelRequested = false;
|
||
int _foldersScanned = 0;
|
||
int _filesScanned = 0;
|
||
String? _error;
|
||
List<CloudFile> _emptyFolders = [];
|
||
List<List<CloudFile>> _duplicates = [];
|
||
List<List<CloudFile>> _similarFolders = [];
|
||
|
||
Future<void> _scan() async {
|
||
final state = ref.read(fileProvider);
|
||
final api = ref.read(authProvider.notifier).api;
|
||
setState(() {
|
||
_scanning = true;
|
||
_cancelRequested = false;
|
||
_foldersScanned = 0;
|
||
_filesScanned = 0;
|
||
_error = null;
|
||
_emptyFolders = [];
|
||
_duplicates = [];
|
||
_similarFolders = [];
|
||
});
|
||
try {
|
||
final empty = <CloudFile>[];
|
||
final groups = <String, List<CloudFile>>{};
|
||
final queue = <CloudFile>[
|
||
...state.files.where((file) => file.isDirectory),
|
||
];
|
||
final files = <CloudFile>[
|
||
...state.files.where((file) => !file.isDirectory),
|
||
];
|
||
final folders = <CloudFile>[
|
||
...state.files.where((file) => file.isDirectory),
|
||
];
|
||
while (queue.isNotEmpty) {
|
||
if (_cancelRequested) break;
|
||
final folder = queue.removeLast();
|
||
final response = await api.fsFiles(parentID: folder.id, pageSize: 1000);
|
||
final children = _extractFiles(response);
|
||
_foldersScanned += 1;
|
||
_filesScanned += children.where((child) => !child.isDirectory).length;
|
||
if (children.isEmpty) empty.add(folder);
|
||
for (final child in children) {
|
||
if (child.isDirectory) {
|
||
queue.add(child);
|
||
folders.add(child);
|
||
} else {
|
||
files.add(child);
|
||
}
|
||
}
|
||
if (mounted) {
|
||
setState(() {
|
||
_emptyFolders = List.of(empty);
|
||
_similarFolders = _groupSimilarFolders(folders);
|
||
});
|
||
}
|
||
}
|
||
for (final file in files) {
|
||
final key = file.gcid;
|
||
if (key != null && key.isNotEmpty) {
|
||
groups.putIfAbsent(key, () => []).add(file);
|
||
}
|
||
}
|
||
if (mounted) {
|
||
setState(() {
|
||
_emptyFolders = empty;
|
||
_duplicates = groups.values
|
||
.where((group) => group.length > 1)
|
||
.toList();
|
||
_similarFolders = _groupSimilarFolders(folders);
|
||
if (_cancelRequested) _error = '扫描已取消,已保留当前结果';
|
||
});
|
||
}
|
||
} catch (error) {
|
||
if (mounted) setState(() => _error = error.toString());
|
||
} finally {
|
||
if (mounted) setState(() => _scanning = false);
|
||
}
|
||
}
|
||
|
||
void _cancelScan() {
|
||
if (!_scanning) return;
|
||
setState(() => _cancelRequested = true);
|
||
}
|
||
|
||
List<List<CloudFile>> _groupSimilarFolders(List<CloudFile> folders) {
|
||
final groups = <String, List<CloudFile>>{};
|
||
for (final folder in folders) {
|
||
final key = _normalizedFolderName(folder.name);
|
||
if (key.isNotEmpty) groups.putIfAbsent(key, () => []).add(folder);
|
||
}
|
||
return groups.values.where((group) => group.length > 1).toList();
|
||
}
|
||
|
||
String _normalizedFolderName(String value) {
|
||
var normalized = value.toLowerCase().trim();
|
||
normalized = normalized.replaceAll(
|
||
RegExp(
|
||
r'(?:[\s._-]*(?:copy|duplicate|backup|副本|复制|拷贝|备份))(?:[\s._-]*\d+)?$',
|
||
caseSensitive: false,
|
||
),
|
||
'',
|
||
);
|
||
normalized = normalized.replaceAll(
|
||
RegExp(r'(?:[((\[]\s*\d{1,3}\s*[))\]])$'),
|
||
'',
|
||
);
|
||
return normalized.replaceAll(RegExp(r'[\s._()\[\]{}-]+'), '');
|
||
}
|
||
|
||
List<CloudFile> _extractFiles(Map<String, dynamic> value) {
|
||
final files = <CloudFile>[];
|
||
void visit(dynamic node) {
|
||
if (node is Map) {
|
||
try {
|
||
files.add(CloudFile.fromJson(Map<String, dynamic>.from(node)));
|
||
} catch (_) {}
|
||
for (final child in node.values) {
|
||
visit(child);
|
||
}
|
||
} else if (node is List) {
|
||
for (final child in node) {
|
||
visit(child);
|
||
}
|
||
}
|
||
}
|
||
|
||
visit(value);
|
||
return {for (final file in files) file.id: file}.values.toList();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return ListView(
|
||
padding: const EdgeInsets.all(18),
|
||
children: [
|
||
_ToolSection(
|
||
title: '当前目录扫描',
|
||
description: _scanning
|
||
? '已扫描 $_foldersScanned 个文件夹、$_filesScanned 个文件。'
|
||
: '检查空文件夹、相同 GCID 的重复文件和相似名称目录。',
|
||
trailing: ShadButton(
|
||
onPressed: _scanning ? _cancelScan : _scan,
|
||
leading: Icon(
|
||
_scanning ? Icons.stop_circle_outlined : Icons.play_arrow_rounded,
|
||
size: 16,
|
||
),
|
||
child: Text(_scanning ? '取消扫描' : '开始扫描'),
|
||
),
|
||
child: _scanning
|
||
? Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: Semantics(
|
||
label: '文件扫描进度:$_foldersScanned 个文件夹,$_filesScanned 个文件',
|
||
child: const ShadProgress(),
|
||
),
|
||
)
|
||
: _error != null
|
||
? Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: Text(_error!, style: TextStyle(color: cs.destructive)),
|
||
)
|
||
: const SizedBox.shrink(),
|
||
),
|
||
const SizedBox(height: 14),
|
||
_ToolSection(
|
||
title: '空文件夹',
|
||
description: '扫描到 ${_emptyFolders.length} 个。',
|
||
child: _CleanupList(files: _emptyFolders, emptyText: '开始扫描后显示空文件夹。'),
|
||
),
|
||
const SizedBox(height: 14),
|
||
_ToolSection(
|
||
title: '重复文件',
|
||
description: '扫描到 ${_duplicates.length} 组。',
|
||
child: _duplicates.isEmpty
|
||
? Padding(
|
||
padding: const EdgeInsets.only(top: 10),
|
||
child: Text(
|
||
'开始扫描后显示重复项。',
|
||
style: TextStyle(color: cs.mutedForeground),
|
||
),
|
||
)
|
||
: Column(
|
||
children: [
|
||
for (final group in _duplicates)
|
||
_DuplicateGroup(files: group),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
_ToolSection(
|
||
title: '相似文件夹',
|
||
description: '扫描到 ${_similarFolders.length} 组,忽略空格、标点和副本后缀。',
|
||
child: _similarFolders.isEmpty
|
||
? Padding(
|
||
padding: const EdgeInsets.only(top: 10),
|
||
child: Text(
|
||
'开始扫描后显示相似名称的文件夹。',
|
||
style: TextStyle(color: cs.mutedForeground),
|
||
),
|
||
)
|
||
: Column(
|
||
children: [
|
||
for (final group in _similarFolders)
|
||
_SimilarFolderGroup(folders: group),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SimilarFolderGroup extends StatelessWidget {
|
||
final List<CloudFile> folders;
|
||
|
||
const _SimilarFolderGroup({required this.folders});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Container(
|
||
margin: const EdgeInsets.only(top: 10),
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: cs.muted,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'相似名称组 · ${folders.length} 个文件夹',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.primary,
|
||
),
|
||
),
|
||
for (final folder in folders)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 7),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.folder_rounded, size: 17, color: cs.primary),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
folder.name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
Text(
|
||
folder.formattedSize,
|
||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CleanupList extends ConsumerWidget {
|
||
final List<CloudFile> files;
|
||
final String emptyText;
|
||
|
||
const _CleanupList({required this.files, required this.emptyText});
|
||
|
||
Future<void> _confirmDeleteEmpty(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
CloudFile folder,
|
||
) async {
|
||
final confirmed = await showShadDialog<bool>(
|
||
context: context,
|
||
builder: (dialogContext) => ShadDialog(
|
||
title: Text('删除空文件夹「${folder.name}」?'),
|
||
actions: [
|
||
ShadButton.outline(
|
||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||
child: const Text('取消'),
|
||
),
|
||
ShadButton.destructive(
|
||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||
child: const Text('重新校验并删除'),
|
||
),
|
||
],
|
||
child: const Padding(
|
||
padding: EdgeInsets.only(top: 10),
|
||
child: Text('删除前会重新检查目录是否为空;已发生变化的目录将被保留。'),
|
||
),
|
||
),
|
||
);
|
||
if (confirmed != true || !context.mounted) return;
|
||
|
||
try {
|
||
final response = await ref
|
||
.read(authProvider.notifier)
|
||
.api
|
||
.fsFiles(parentID: folder.id, pageSize: 1);
|
||
if (_hasCloudFile(response)) {
|
||
if (!context.mounted) return;
|
||
await showShadDialog<void>(
|
||
context: context,
|
||
builder: (dialogContext) => ShadDialog(
|
||
title: const Text('已跳过删除'),
|
||
actions: [
|
||
ShadButton(
|
||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||
child: const Text('知道了'),
|
||
),
|
||
],
|
||
child: const Padding(
|
||
padding: EdgeInsets.only(top: 10),
|
||
child: Text('该文件夹在扫描后已有内容,未执行删除。'),
|
||
),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
await ref.read(fileProvider.notifier).deleteFiles([folder]);
|
||
} catch (_) {
|
||
if (!context.mounted) return;
|
||
await showShadDialog<void>(
|
||
context: context,
|
||
builder: (dialogContext) => ShadDialog(
|
||
title: const Text('无法校验文件夹'),
|
||
actions: [
|
||
ShadButton(
|
||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||
child: const Text('关闭'),
|
||
),
|
||
],
|
||
child: const Padding(
|
||
padding: EdgeInsets.only(top: 10),
|
||
child: Text('未能重新读取目录内容,因此没有执行删除。'),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
bool _hasCloudFile(Map<String, dynamic> value) {
|
||
var found = false;
|
||
void visit(dynamic node) {
|
||
if (found) return;
|
||
if (node is Map) {
|
||
try {
|
||
CloudFile.fromJson(Map<String, dynamic>.from(node));
|
||
found = true;
|
||
return;
|
||
} catch (_) {}
|
||
for (final child in node.values) {
|
||
visit(child);
|
||
}
|
||
} else if (node is List) {
|
||
for (final child in node) {
|
||
visit(child);
|
||
}
|
||
}
|
||
}
|
||
|
||
visit(value);
|
||
return found;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
if (files.isEmpty) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 10),
|
||
child: Text(emptyText, style: TextStyle(color: cs.mutedForeground)),
|
||
);
|
||
}
|
||
return Column(
|
||
children: [
|
||
for (final file in files)
|
||
ListTile(
|
||
dense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
leading: Icon(Icons.folder_rounded, color: cs.primary),
|
||
title: Text(file.name),
|
||
trailing: ShadButton.destructive(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: () => _confirmDeleteEmpty(context, ref, file),
|
||
child: const Text('删除'),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DuplicateGroup extends ConsumerStatefulWidget {
|
||
final List<CloudFile> files;
|
||
|
||
const _DuplicateGroup({required this.files});
|
||
|
||
@override
|
||
ConsumerState<_DuplicateGroup> createState() => _DuplicateGroupState();
|
||
}
|
||
|
||
class _DuplicateGroupState extends ConsumerState<_DuplicateGroup> {
|
||
late Set<String> _selectedIDs;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_selectedIDs = widget.files.skip(1).map((file) => file.id).toSet();
|
||
}
|
||
|
||
Future<void> _confirmDelete() async {
|
||
final targets = widget.files
|
||
.where((file) => _selectedIDs.contains(file.id))
|
||
.toList();
|
||
if (targets.isEmpty) return;
|
||
final confirmed = await showShadDialog<bool>(
|
||
context: context,
|
||
builder: (dialogContext) => ShadDialog(
|
||
title: Text('删除 ${targets.length} 个重复文件?'),
|
||
actions: [
|
||
ShadButton.outline(
|
||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||
child: const Text('取消'),
|
||
),
|
||
ShadButton.destructive(
|
||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||
child: const Text('删除已选项'),
|
||
),
|
||
],
|
||
child: const Padding(
|
||
padding: EdgeInsets.only(top: 10),
|
||
child: Text('同组至少会保留一项。删除后无法恢复,请确认选中的文件。'),
|
||
),
|
||
),
|
||
);
|
||
if (confirmed != true || !mounted) return;
|
||
await ref.read(fileProvider.notifier).deleteFiles(targets);
|
||
if (mounted) setState(_selectedIDs.clear);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final selectedCount = _selectedIDs.length;
|
||
return Container(
|
||
margin: const EdgeInsets.only(top: 10),
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: cs.muted,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
'重复组 · ${widget.files.length} 项',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.primary,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
ShadButton.destructive(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: selectedCount == 0 ? null : _confirmDelete,
|
||
child: Text('删除已选 $selectedCount 项'),
|
||
),
|
||
],
|
||
),
|
||
for (final file in widget.files)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 7),
|
||
child: Row(
|
||
children: [
|
||
ShadCheckbox(
|
||
value: _selectedIDs.contains(file.id),
|
||
onChanged: (value) => setState(() {
|
||
if (value == true) {
|
||
if (_selectedIDs.length < widget.files.length - 1) {
|
||
_selectedIDs.add(file.id);
|
||
}
|
||
} else {
|
||
_selectedIDs.remove(file.id);
|
||
}
|
||
}),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
file.name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
Text(
|
||
file.cloudPath,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: cs.mutedForeground,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Text(
|
||
_selectedIDs.contains(file.id) ? '将删除' : '保留',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: _selectedIDs.contains(file.id)
|
||
? cs.destructive
|
||
: cs.primary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _BatchRenameTool extends ConsumerStatefulWidget {
|
||
const _BatchRenameTool();
|
||
|
||
@override
|
||
ConsumerState<_BatchRenameTool> createState() => _BatchRenameToolState();
|
||
}
|
||
|
||
class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> {
|
||
final _filter = TextEditingController();
|
||
bool _preserveExtension = true;
|
||
bool _running = false;
|
||
int _completed = 0;
|
||
int _total = 0;
|
||
String _status = '';
|
||
BatchRenameItemType _itemType = BatchRenameItemType.all;
|
||
final _selectedIDs = <String>{};
|
||
late List<CloudFile> _candidates;
|
||
late String? _sourceID;
|
||
late String _sourceLabel;
|
||
var _recursive = false;
|
||
var _loadingCandidates = false;
|
||
late List<BatchRenameRule> _rules;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final state = ref.read(fileProvider);
|
||
_candidates = state.files;
|
||
_sourceID = state.folderPath.isEmpty ? null : state.folderPath.last.id;
|
||
_sourceLabel = state.folderPath.isEmpty
|
||
? '云盘根目录'
|
||
: state.folderPath.map((folder) => folder.name).join(' / ');
|
||
_rules = [
|
||
const BatchRenameRule(id: 'rule-0', kind: BatchRenameRuleKind.replace),
|
||
];
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_filter.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _updateRule(int index, BatchRenameRule rule) {
|
||
setState(() => _rules[index] = rule);
|
||
}
|
||
|
||
void _moveRule(int index, int offset) {
|
||
final destination = index + offset;
|
||
if (destination < 0 || destination >= _rules.length) return;
|
||
setState(() {
|
||
final next = List<BatchRenameRule>.of(_rules);
|
||
final rule = next[index];
|
||
next[index] = next[destination];
|
||
next[destination] = rule;
|
||
_rules = next;
|
||
});
|
||
}
|
||
|
||
Future<void> _pickSource() async {
|
||
final selection = await showShadDialog<_BatchRenameFolderSelection>(
|
||
context: context,
|
||
builder: (_) => _BatchRenameFolderPicker(initialID: _sourceID),
|
||
);
|
||
if (selection == null || !mounted) return;
|
||
setState(() {
|
||
_sourceID = selection.id;
|
||
_sourceLabel = selection.label;
|
||
});
|
||
await _loadCandidates();
|
||
}
|
||
|
||
Future<void> _loadCandidates() async {
|
||
if (_loadingCandidates) return;
|
||
setState(() {
|
||
_loadingCandidates = true;
|
||
_status = '';
|
||
});
|
||
try {
|
||
final api = ref.read(authProvider.notifier).api;
|
||
final result = <CloudFile>[];
|
||
final visitedFolders = <String>{};
|
||
final queue = <_BatchRenameDirectoryNode>[
|
||
_BatchRenameDirectoryNode(_sourceID, ''),
|
||
];
|
||
while (queue.isNotEmpty) {
|
||
final node = queue.removeLast();
|
||
final response = await api.fsFiles(parentID: node.id, pageSize: 1000);
|
||
final children = _extractCandidates(response);
|
||
for (final child in children) {
|
||
final path = node.path.isEmpty
|
||
? child.name
|
||
: '${node.path}/${child.name}';
|
||
final candidate = child.copyWith(cloudPath: path);
|
||
result.add(candidate);
|
||
if (_recursive &&
|
||
candidate.isDirectory &&
|
||
visitedFolders.add(candidate.id)) {
|
||
queue.add(_BatchRenameDirectoryNode(candidate.id, path));
|
||
}
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_candidates = {
|
||
for (final file in result) file.id: file,
|
||
}.values.toList();
|
||
_selectedIDs
|
||
..clear()
|
||
..addAll(_candidates.map((file) => file.id));
|
||
_status = '已读取 ${_candidates.length} 项';
|
||
});
|
||
} catch (error) {
|
||
if (mounted) setState(() => _status = error.toString());
|
||
} finally {
|
||
if (mounted) setState(() => _loadingCandidates = false);
|
||
}
|
||
}
|
||
|
||
List<CloudFile> _extractCandidates(Map<String, dynamic> value) {
|
||
final files = <CloudFile>[];
|
||
void visit(dynamic node) {
|
||
if (node is Map) {
|
||
try {
|
||
files.add(CloudFile.fromJson(Map<String, dynamic>.from(node)));
|
||
} catch (_) {}
|
||
for (final child in node.values) {
|
||
visit(child);
|
||
}
|
||
} else if (node is List) {
|
||
for (final child in node) {
|
||
visit(child);
|
||
}
|
||
}
|
||
}
|
||
|
||
visit(value);
|
||
return {for (final file in files) file.id: file}.values.toList();
|
||
}
|
||
|
||
Future<void> _confirmAndApply(List<BatchRenamePreview> previews) async {
|
||
final changes = previews
|
||
.where(
|
||
(preview) =>
|
||
preview.applicable && _selectedIDs.contains(preview.file.id),
|
||
)
|
||
.toList();
|
||
if (changes.isEmpty || _running) return;
|
||
final confirmed = await showShadDialog<bool>(
|
||
context: context,
|
||
builder: (dialogContext) => ShadDialog(
|
||
title: Text('应用 ${changes.length} 项重命名?'),
|
||
actions: [
|
||
ShadButton.outline(
|
||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||
child: const Text('取消'),
|
||
),
|
||
ShadButton(
|
||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||
child: const Text('应用重命名'),
|
||
),
|
||
],
|
||
child: const Padding(
|
||
padding: EdgeInsets.only(top: 10),
|
||
child: Text('名称修改会直接同步到云盘,请确认预览结果。'),
|
||
),
|
||
),
|
||
);
|
||
if (confirmed == true) await _apply(changes);
|
||
}
|
||
|
||
Future<void> _apply(List<BatchRenamePreview> changes) async {
|
||
setState(() {
|
||
_running = true;
|
||
_completed = 0;
|
||
_total = changes.length;
|
||
_status = '';
|
||
});
|
||
final api = ref.read(authProvider.notifier).api;
|
||
var succeeded = 0;
|
||
var failed = 0;
|
||
try {
|
||
for (final change in changes) {
|
||
if (!mounted) return;
|
||
setState(() => _status = change.file.name);
|
||
try {
|
||
await api.fsRename(change.file.id, change.newName);
|
||
succeeded += 1;
|
||
} catch (_) {
|
||
failed += 1;
|
||
}
|
||
if (mounted) setState(() => _completed += 1);
|
||
}
|
||
await ref.read(fileProvider.notifier).loadFiles();
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() {
|
||
_running = false;
|
||
_selectedIDs.clear();
|
||
_status = failed == 0
|
||
? '已完成 $succeeded 项重命名'
|
||
: '已完成 $succeeded 项,失败 $failed 项';
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final files = _candidates;
|
||
final allPreviews = buildRenamePreviews(
|
||
files,
|
||
_rules,
|
||
preserveExtension: _preserveExtension,
|
||
);
|
||
final filterText = _filter.text.trim().toLowerCase();
|
||
final previews = allPreviews.where((preview) {
|
||
final matchesType = switch (_itemType) {
|
||
BatchRenameItemType.all => true,
|
||
BatchRenameItemType.files => !preview.file.isDirectory,
|
||
BatchRenameItemType.folders => preview.file.isDirectory,
|
||
};
|
||
return matchesType &&
|
||
(filterText.isEmpty ||
|
||
preview.file.name.toLowerCase().contains(filterText));
|
||
}).toList();
|
||
final applicable = previews.where((preview) => preview.applicable).toList();
|
||
final selectedCount = applicable
|
||
.where((preview) => _selectedIDs.contains(preview.file.id))
|
||
.length;
|
||
return ListView(
|
||
padding: const EdgeInsets.all(18),
|
||
children: [
|
||
_ToolSection(
|
||
title: '数据源',
|
||
description:
|
||
'$_sourceLabel,已读取 ${files.length} 项;筛选后显示 ${previews.length} 项。',
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: Wrap(
|
||
spacing: 10,
|
||
runSpacing: 10,
|
||
children: [
|
||
SizedBox(
|
||
width: 132,
|
||
child: ShadSelect<BatchRenameItemType>(
|
||
key: ValueKey(_itemType),
|
||
initialValue: _itemType,
|
||
selectedOptionBuilder: (_, value) =>
|
||
Text(_itemTypeLabel(value)),
|
||
options: [
|
||
for (final value in BatchRenameItemType.values)
|
||
ShadOption(
|
||
value: value,
|
||
child: Text(_itemTypeLabel(value)),
|
||
),
|
||
],
|
||
onChanged: (value) {
|
||
if (value != null) setState(() => _itemType = value);
|
||
},
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 220,
|
||
child: ShadInput(
|
||
controller: _filter,
|
||
placeholder: const Text('按原名称过滤'),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
),
|
||
ShadCheckbox(
|
||
value: _preserveExtension,
|
||
label: const Text('保留扩展名'),
|
||
onChanged: (value) =>
|
||
setState(() => _preserveExtension = value),
|
||
),
|
||
ShadCheckbox(
|
||
value: _recursive,
|
||
label: const Text('包含子文件夹'),
|
||
onChanged: _loadingCandidates
|
||
? null
|
||
: (value) async {
|
||
setState(() => _recursive = value);
|
||
await _loadCandidates();
|
||
},
|
||
),
|
||
ShadButton.outline(
|
||
onPressed: _loadingCandidates ? null : _pickSource,
|
||
leading: const Icon(Icons.folder_open_rounded, size: 16),
|
||
child: const Text('选择目录'),
|
||
),
|
||
ShadButton.ghost(
|
||
onPressed: _loadingCandidates ? null : _loadCandidates,
|
||
leading: Icon(
|
||
_loadingCandidates
|
||
? Icons.hourglass_top_rounded
|
||
: Icons.refresh_rounded,
|
||
size: 16,
|
||
),
|
||
child: Text(_loadingCandidates ? '读取中' : '重新读取'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
_ToolSection(
|
||
title: '规则链',
|
||
description: '规则按从上到下的顺序应用。',
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: Column(
|
||
children: [
|
||
for (var index = 0; index < _rules.length; index++)
|
||
_BatchRenameRuleRow(
|
||
key: ValueKey(_rules[index].id),
|
||
rule: _rules[index],
|
||
isFirst: index == 0,
|
||
isLast: index == _rules.length - 1,
|
||
onChanged: (rule) => _updateRule(index, rule),
|
||
onMoveUp: () => _moveRule(index, -1),
|
||
onMoveDown: () => _moveRule(index, 1),
|
||
onRemove: () => setState(() {
|
||
_rules.removeAt(index);
|
||
if (_rules.isEmpty) {
|
||
_rules = const [
|
||
BatchRenameRule(
|
||
id: 'rule-0',
|
||
kind: BatchRenameRuleKind.replace,
|
||
),
|
||
];
|
||
}
|
||
}),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Row(
|
||
children: [
|
||
ShadButton.outline(
|
||
onPressed: () => setState(() {
|
||
_rules.add(
|
||
BatchRenameRule(
|
||
id: 'rule-${DateTime.now().microsecondsSinceEpoch}',
|
||
kind: BatchRenameRuleKind.replace,
|
||
),
|
||
);
|
||
}),
|
||
leading: const Icon(Icons.add_rounded, size: 16),
|
||
child: const Text('添加规则'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
ShadButton.ghost(
|
||
onPressed: () => setState(() {
|
||
_rules = const [
|
||
BatchRenameRule(
|
||
id: 'rule-0',
|
||
kind: BatchRenameRuleKind.replace,
|
||
),
|
||
];
|
||
}),
|
||
child: const Text('清空规则'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
_ToolSection(
|
||
title: '预览',
|
||
description: '可应用 ${applicable.length} 项,已选择 $selectedCount 项。',
|
||
trailing: ShadButton(
|
||
onPressed: _running || selectedCount == 0
|
||
? null
|
||
: () => _confirmAndApply(previews),
|
||
leading: Icon(
|
||
_running ? Icons.hourglass_top_rounded : Icons.play_arrow_rounded,
|
||
size: 16,
|
||
),
|
||
child: Text(_running ? '正在应用 $_completed / $_total' : '应用重命名'),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
ShadButton.outline(
|
||
onPressed: _running
|
||
? null
|
||
: () => setState(() {
|
||
_selectedIDs
|
||
..clear()
|
||
..addAll(
|
||
applicable.map((item) => item.file.id),
|
||
);
|
||
}),
|
||
child: const Text('全选可应用项'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
ShadButton.ghost(
|
||
onPressed: _running
|
||
? null
|
||
: () => setState(_selectedIDs.clear),
|
||
child: const Text('全不选'),
|
||
),
|
||
const Spacer(),
|
||
if (_status.isNotEmpty)
|
||
Expanded(
|
||
child: Text(
|
||
_status,
|
||
textAlign: TextAlign.right,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: ShadTheme.of(
|
||
context,
|
||
).colorScheme.mutedForeground,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
Container(
|
||
height: 360,
|
||
decoration: BoxDecoration(
|
||
border: Border.all(
|
||
color: ShadTheme.of(context).colorScheme.border,
|
||
),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: previews.isEmpty
|
||
? Center(
|
||
child: Text(
|
||
'当前筛选条件下没有项目',
|
||
style: TextStyle(
|
||
color: ShadTheme.of(
|
||
context,
|
||
).colorScheme.mutedForeground,
|
||
),
|
||
),
|
||
)
|
||
: ListView.separated(
|
||
itemCount: previews.length,
|
||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||
itemBuilder: (context, index) {
|
||
final preview = previews[index];
|
||
final canSelect = preview.applicable;
|
||
return _BatchRenamePreviewRow(
|
||
preview: preview,
|
||
selected: _selectedIDs.contains(preview.file.id),
|
||
enabled: canSelect && !_running,
|
||
onChanged: (selected) => setState(() {
|
||
if (selected == true && canSelect) {
|
||
_selectedIDs.add(preview.file.id);
|
||
} else {
|
||
_selectedIDs.remove(preview.file.id);
|
||
}
|
||
}),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
String _itemTypeLabel(BatchRenameItemType value) => switch (value) {
|
||
BatchRenameItemType.all => '全部项目',
|
||
BatchRenameItemType.files => '仅文件',
|
||
BatchRenameItemType.folders => '仅文件夹',
|
||
};
|
||
|
||
String _ruleKindLabel(BatchRenameRuleKind value) => switch (value) {
|
||
BatchRenameRuleKind.remove => '删除字符',
|
||
BatchRenameRuleKind.replace => '查找替换',
|
||
BatchRenameRuleKind.regex => '正则替换',
|
||
BatchRenameRuleKind.prefix => '添加前缀',
|
||
BatchRenameRuleKind.suffix => '添加后缀',
|
||
};
|
||
|
||
String _rulePatternPlaceholder(BatchRenameRuleKind value) => switch (value) {
|
||
BatchRenameRuleKind.remove => '要删除的字符',
|
||
BatchRenameRuleKind.replace => '查找内容',
|
||
BatchRenameRuleKind.regex => '正则表达式,例如 \\s+',
|
||
BatchRenameRuleKind.prefix => '前缀',
|
||
BatchRenameRuleKind.suffix => '后缀',
|
||
};
|
||
|
||
class _BatchRenameDirectoryNode {
|
||
final String? id;
|
||
final String path;
|
||
|
||
const _BatchRenameDirectoryNode(this.id, this.path);
|
||
}
|
||
|
||
class _BatchRenameFolderSelection {
|
||
final String? id;
|
||
final String label;
|
||
|
||
const _BatchRenameFolderSelection(this.id, this.label);
|
||
}
|
||
|
||
class _BatchRenameFolderPicker extends ConsumerStatefulWidget {
|
||
final String? initialID;
|
||
|
||
const _BatchRenameFolderPicker({required this.initialID});
|
||
|
||
@override
|
||
ConsumerState<_BatchRenameFolderPicker> createState() =>
|
||
_BatchRenameFolderPickerState();
|
||
}
|
||
|
||
class _BatchRenameFolderPickerState
|
||
extends ConsumerState<_BatchRenameFolderPicker> {
|
||
final _path = <CloudFile>[];
|
||
var _folders = <CloudFile>[];
|
||
var _loading = true;
|
||
String? _error;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
Future.microtask(_load);
|
||
}
|
||
|
||
String? get _parentID => _path.isEmpty ? null : _path.last.id;
|
||
String get _label =>
|
||
_path.isEmpty ? '云盘根目录' : _path.map((folder) => folder.name).join(' / ');
|
||
|
||
Future<void> _load() async {
|
||
setState(() {
|
||
_loading = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
final response = await ref
|
||
.read(authProvider.notifier)
|
||
.api
|
||
.fsFiles(parentID: _parentID, pageSize: 1000);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_folders = _extractFolders(
|
||
response,
|
||
)..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||
});
|
||
} catch (error) {
|
||
if (mounted) setState(() => _error = error.toString());
|
||
} finally {
|
||
if (mounted) setState(() => _loading = false);
|
||
}
|
||
}
|
||
|
||
List<CloudFile> _extractFolders(Map<String, dynamic> value) {
|
||
final folders = <CloudFile>[];
|
||
void visit(dynamic node) {
|
||
if (node is Map) {
|
||
try {
|
||
final file = CloudFile.fromJson(Map<String, dynamic>.from(node));
|
||
if (file.isDirectory) folders.add(file);
|
||
} catch (_) {}
|
||
for (final child in node.values) {
|
||
visit(child);
|
||
}
|
||
} else if (node is List) {
|
||
for (final child in node) {
|
||
visit(child);
|
||
}
|
||
}
|
||
}
|
||
|
||
visit(value);
|
||
return {for (final folder in folders) folder.id: folder}.values.toList();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return ShadDialog(
|
||
title: const Text('选择重命名目录'),
|
||
description: Text('当前:$_label'),
|
||
actions: [
|
||
ShadButton.outline(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('取消'),
|
||
),
|
||
ShadButton(
|
||
onPressed: () => Navigator.of(
|
||
context,
|
||
).pop(_BatchRenameFolderSelection(_parentID, _label)),
|
||
leading: const Icon(Icons.check_rounded, size: 16),
|
||
child: const Text('使用此目录'),
|
||
),
|
||
],
|
||
child: SizedBox(
|
||
width: 540,
|
||
height: 360,
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
ShadButton.ghost(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: _path.isEmpty
|
||
? null
|
||
: () {
|
||
setState(() => _path.removeLast());
|
||
_load();
|
||
},
|
||
leading: const Icon(Icons.arrow_back_rounded, size: 16),
|
||
child: const Text('返回上级'),
|
||
),
|
||
const Spacer(),
|
||
ShadButton.ghost(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: _loading ? null : _load,
|
||
child: const Icon(Icons.refresh_rounded, size: 16),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Expanded(
|
||
child: _loading
|
||
? const Center(child: ShadProgress())
|
||
: _error != null
|
||
? Center(
|
||
child: Text(
|
||
_error!,
|
||
style: TextStyle(color: cs.destructive),
|
||
),
|
||
)
|
||
: _folders.isEmpty
|
||
? Center(
|
||
child: Text(
|
||
'没有子文件夹',
|
||
style: TextStyle(color: cs.mutedForeground),
|
||
),
|
||
)
|
||
: ListView.separated(
|
||
itemCount: _folders.length,
|
||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||
itemBuilder: (context, index) {
|
||
final folder = _folders[index];
|
||
return ListTile(
|
||
leading: Icon(
|
||
Icons.folder_rounded,
|
||
color: cs.primary,
|
||
),
|
||
title: Text(folder.name),
|
||
trailing: const Icon(Icons.chevron_right_rounded),
|
||
onTap: () {
|
||
setState(() => _path.add(folder));
|
||
_load();
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _BatchRenameRuleRow extends StatelessWidget {
|
||
final BatchRenameRule rule;
|
||
final bool isFirst;
|
||
final bool isLast;
|
||
final ValueChanged<BatchRenameRule> onChanged;
|
||
final VoidCallback onMoveUp;
|
||
final VoidCallback onMoveDown;
|
||
final VoidCallback onRemove;
|
||
|
||
const _BatchRenameRuleRow({
|
||
super.key,
|
||
required this.rule,
|
||
required this.isFirst,
|
||
required this.isLast,
|
||
required this.onChanged,
|
||
required this.onMoveUp,
|
||
required this.onMoveDown,
|
||
required this.onRemove,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final hasReplacement =
|
||
rule.kind == BatchRenameRuleKind.replace ||
|
||
rule.kind == BatchRenameRuleKind.regex;
|
||
final supportsCase =
|
||
hasReplacement || rule.kind == BatchRenameRuleKind.remove;
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: cs.muted.withValues(alpha: 0.46),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: cs.border.withValues(alpha: 0.65)),
|
||
),
|
||
child: Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
ShadCheckbox(
|
||
value: rule.enabled,
|
||
label: const Text('启用'),
|
||
onChanged: (value) => onChanged(rule.copyWith(enabled: value)),
|
||
),
|
||
SizedBox(
|
||
width: 132,
|
||
child: ShadSelect<BatchRenameRuleKind>(
|
||
key: ValueKey('${rule.id}-${rule.kind}'),
|
||
initialValue: rule.kind,
|
||
selectedOptionBuilder: (_, value) => Text(_ruleKindLabel(value)),
|
||
options: [
|
||
for (final value in BatchRenameRuleKind.values)
|
||
ShadOption(value: value, child: Text(_ruleKindLabel(value))),
|
||
],
|
||
onChanged: (value) {
|
||
if (value != null) onChanged(rule.copyWith(kind: value));
|
||
},
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 190,
|
||
child: ShadInput(
|
||
initialValue: rule.pattern,
|
||
placeholder: Text(_rulePatternPlaceholder(rule.kind)),
|
||
onChanged: (value) => onChanged(rule.copyWith(pattern: value)),
|
||
),
|
||
),
|
||
if (hasReplacement) ...[
|
||
const Icon(Icons.arrow_right_alt_rounded, size: 18),
|
||
SizedBox(
|
||
width: 160,
|
||
child: ShadInput(
|
||
initialValue: rule.replacement,
|
||
placeholder: const Text('替换为'),
|
||
onChanged: (value) =>
|
||
onChanged(rule.copyWith(replacement: value)),
|
||
),
|
||
),
|
||
],
|
||
if (supportsCase)
|
||
ShadCheckbox(
|
||
value: rule.ignoreCase,
|
||
label: const Text('忽略大小写'),
|
||
onChanged: (value) => onChanged(rule.copyWith(ignoreCase: value)),
|
||
),
|
||
_RenameRuleIconButton(
|
||
icon: Icons.arrow_upward_rounded,
|
||
tooltip: '上移规则',
|
||
enabled: !isFirst,
|
||
onPressed: onMoveUp,
|
||
),
|
||
_RenameRuleIconButton(
|
||
icon: Icons.arrow_downward_rounded,
|
||
tooltip: '下移规则',
|
||
enabled: !isLast,
|
||
onPressed: onMoveDown,
|
||
),
|
||
_RenameRuleIconButton(
|
||
icon: Icons.delete_outline_rounded,
|
||
tooltip: '删除规则',
|
||
enabled: true,
|
||
destructive: true,
|
||
onPressed: onRemove,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _RenameRuleIconButton extends StatelessWidget {
|
||
final IconData icon;
|
||
final String tooltip;
|
||
final bool enabled;
|
||
final bool destructive;
|
||
final VoidCallback onPressed;
|
||
|
||
const _RenameRuleIconButton({
|
||
required this.icon,
|
||
required this.tooltip,
|
||
required this.enabled,
|
||
required this.onPressed,
|
||
this.destructive = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return ShadTooltip(
|
||
builder: (_) => Text(tooltip),
|
||
child: ShadButton.ghost(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: enabled ? onPressed : null,
|
||
child: Icon(icon, size: 16, color: destructive ? cs.destructive : null),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _BatchRenamePreviewRow extends StatelessWidget {
|
||
final BatchRenamePreview preview;
|
||
final bool selected;
|
||
final bool enabled;
|
||
final ValueChanged<bool?> onChanged;
|
||
|
||
const _BatchRenamePreviewRow({
|
||
required this.preview,
|
||
required this.selected,
|
||
required this.enabled,
|
||
required this.onChanged,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
final changed = preview.changed;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||
child: Row(
|
||
children: [
|
||
ShadCheckbox(value: selected, onChanged: enabled ? onChanged : null),
|
||
const SizedBox(width: 10),
|
||
Icon(
|
||
preview.file.isDirectory
|
||
? Icons.folder_rounded
|
||
: Icons.insert_drive_file_rounded,
|
||
size: 18,
|
||
color: preview.file.isDirectory ? cs.primary : cs.mutedForeground,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
preview.file.name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
preview.newName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: preview.error != null
|
||
? cs.destructive
|
||
: changed
|
||
? cs.primary
|
||
: cs.mutedForeground,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
preview.error ?? (changed ? '将修改' : '无变化'),
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: preview.error != null
|
||
? cs.destructive
|
||
: changed
|
||
? cs.primary
|
||
: cs.mutedForeground,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FastTransferTool extends ConsumerStatefulWidget {
|
||
const _FastTransferTool();
|
||
|
||
@override
|
||
ConsumerState<_FastTransferTool> createState() => _FastTransferToolState();
|
||
}
|
||
|
||
class _FastTransferToolState extends ConsumerState<_FastTransferTool> {
|
||
final _json = TextEditingController();
|
||
bool _running = false;
|
||
bool _paused = false;
|
||
bool _cancelRequested = false;
|
||
bool _createDirectories = true;
|
||
bool _skipExisting = true;
|
||
String _result = '';
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final raw = StorageManager.get<dynamic>(StorageKeys.fastTransferSession);
|
||
if (raw is Map && raw['entries'] is List) {
|
||
final entries = (raw['entries'] as List)
|
||
.whereType<Map>()
|
||
.map(
|
||
(value) =>
|
||
FastTransferEntry.fromJson(Map<String, dynamic>.from(value)),
|
||
)
|
||
.where((entry) => entry.path.isNotEmpty)
|
||
.toList();
|
||
if (entries.isNotEmpty) {
|
||
_json.text = jsonEncode({
|
||
'files': entries.map((entry) => entry.toJson()).toList(),
|
||
});
|
||
}
|
||
_result = raw['result']?.toString() ?? '';
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_json.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
late final List<FastTransferEntry> entries;
|
||
try {
|
||
entries = parseFastTransferJSON(_json.text);
|
||
} catch (error) {
|
||
setState(() => _result = error.toString());
|
||
return;
|
||
}
|
||
setState(() {
|
||
_running = true;
|
||
_paused = false;
|
||
_cancelRequested = false;
|
||
_result = '';
|
||
});
|
||
await StorageManager.set(StorageKeys.fastTransferSession, {
|
||
'entries': entries.map((entry) => entry.toJson()).toList(),
|
||
'result': '待执行 ${entries.length} 项',
|
||
});
|
||
var completed = 0;
|
||
var failed = 0;
|
||
var nextIndex = 0;
|
||
final api = ref.read(authProvider.notifier).api;
|
||
final parentID = ref.read(fileProvider).folderPath.lastOrNull?.id;
|
||
final directoryCache = <String, String?>{};
|
||
final concurrency =
|
||
(int.tryParse(
|
||
StorageManager.get<String>(
|
||
StorageKeys.fastTransferConcurrency,
|
||
) ??
|
||
'3',
|
||
) ??
|
||
3)
|
||
.clamp(1, 20);
|
||
try {
|
||
Future<void> worker() async {
|
||
while (nextIndex < entries.length && !_cancelRequested) {
|
||
while (_paused && !_cancelRequested) {
|
||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||
}
|
||
if (_cancelRequested) return;
|
||
final entry = entries[nextIndex++];
|
||
try {
|
||
final targetID = await _resolveTargetDirectory(
|
||
entry,
|
||
parentID,
|
||
api,
|
||
directoryCache,
|
||
);
|
||
if (_skipExisting &&
|
||
await _hasExistingFile(api, targetID, entry.name)) {
|
||
completed += 1;
|
||
continue;
|
||
}
|
||
if (entry.md5 != null) {
|
||
await api.flashTransferToken(
|
||
name: entry.name,
|
||
fileSize: entry.size,
|
||
parentID: targetID,
|
||
md5: entry.md5!,
|
||
);
|
||
} else {
|
||
await api.flashTransferGCIDToken(
|
||
name: entry.name,
|
||
fileSize: entry.size,
|
||
parentID: targetID,
|
||
gcid: entry.gcid!,
|
||
);
|
||
}
|
||
completed += 1;
|
||
} catch (_) {
|
||
failed += 1;
|
||
}
|
||
if (mounted) {
|
||
setState(
|
||
() => _result = '已处理 ${completed + failed}/${entries.length} 项',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
await Future.wait(List.generate(concurrency, (_) => worker()));
|
||
await ref.read(fileProvider.notifier).loadFiles();
|
||
if (mounted) {
|
||
setState(
|
||
() => _result = _cancelRequested
|
||
? '秒传已终止:成功 $completed,失败 $failed,待处理 ${entries.length - completed - failed}'
|
||
: '秒传完成:成功 $completed,失败 $failed',
|
||
);
|
||
}
|
||
await StorageManager.set(StorageKeys.fastTransferSession, {
|
||
'entries': entries.map((entry) => entry.toJson()).toList(),
|
||
'result': _cancelRequested
|
||
? '秒传已终止:成功 $completed,失败 $failed'
|
||
: '秒传完成:成功 $completed,失败 $failed',
|
||
});
|
||
} catch (error) {
|
||
if (mounted) {
|
||
setState(() => _result = '已提交 $completed / ${entries.length} 个:$error');
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _running = false);
|
||
}
|
||
}
|
||
|
||
Future<String?> _resolveTargetDirectory(
|
||
FastTransferEntry entry,
|
||
String? rootID,
|
||
dynamic api,
|
||
Map<String, String?> cache,
|
||
) async {
|
||
var currentID = rootID;
|
||
for (final name
|
||
in entry.directoryPath.split('/').where((part) => part.isNotEmpty)) {
|
||
if (name == '..') throw const FormatException('目录不能包含 ..');
|
||
final key = '${currentID ?? 'root'}/$name';
|
||
if (cache.containsKey(key)) {
|
||
currentID = cache[key];
|
||
continue;
|
||
}
|
||
final children = _extractFiles(
|
||
await api.fsFiles(parentID: currentID, pageSize: 1000),
|
||
);
|
||
final existing = children.where((file) => file.name == name).firstOrNull;
|
||
if (existing != null) {
|
||
if (!existing.isDirectory) {
|
||
throw FormatException('$name 已被同名文件占用');
|
||
}
|
||
currentID = existing.id;
|
||
} else {
|
||
if (!_createDirectories) {
|
||
throw FormatException('${entry.path} 包含目录,请开启自动创建目录');
|
||
}
|
||
final parentBeforeCreate = currentID;
|
||
final response = await api.fsCreateDir(
|
||
name,
|
||
parentID: parentBeforeCreate,
|
||
);
|
||
currentID = _findString(response, const [
|
||
'fileId',
|
||
'file_id',
|
||
'id',
|
||
'resId',
|
||
]);
|
||
if (currentID == null) {
|
||
final refreshed = _extractFiles(
|
||
await api.fsFiles(parentID: parentBeforeCreate, pageSize: 1000),
|
||
);
|
||
currentID = refreshed
|
||
.where((file) => file.name == name && file.isDirectory)
|
||
.firstOrNull
|
||
?.id;
|
||
}
|
||
if (currentID == null) throw FormatException('无法创建目录 $name');
|
||
}
|
||
cache[key] = currentID;
|
||
}
|
||
return currentID;
|
||
}
|
||
|
||
Future<bool> _hasExistingFile(
|
||
dynamic api,
|
||
String? parentID,
|
||
String name,
|
||
) async => _extractFiles(
|
||
await api.fsFiles(parentID: parentID, pageSize: 1000),
|
||
).any((file) => !file.isDirectory && file.name == name);
|
||
|
||
List<CloudFile> _extractFiles(Map<String, dynamic> json) {
|
||
final files = <CloudFile>[];
|
||
void visit(dynamic value) {
|
||
if (value is Map) {
|
||
try {
|
||
files.add(CloudFile.fromJson(Map<String, dynamic>.from(value)));
|
||
} catch (_) {}
|
||
for (final child in value.values) {
|
||
visit(child);
|
||
}
|
||
} else if (value is List) {
|
||
for (final child in value) {
|
||
visit(child);
|
||
}
|
||
}
|
||
}
|
||
|
||
visit(json);
|
||
final unique = <String, CloudFile>{for (final file in files) file.id: file};
|
||
return unique.values.toList();
|
||
}
|
||
|
||
String? _findString(Map<String, dynamic> value, List<String> keys) {
|
||
for (final key in keys) {
|
||
final found = value[key];
|
||
if (found != null && found.toString().isNotEmpty) return found.toString();
|
||
}
|
||
for (final child in value.values) {
|
||
if (child is Map) {
|
||
final found = _findString(Map<String, dynamic>.from(child), keys);
|
||
if (found != null) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Padding(
|
||
padding: const EdgeInsets.all(18),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_ToolSection(
|
||
title: '导入秒传 JSON',
|
||
description: '支持递归解析包含 name、size、gcid(或 gcId)的 JSON。任务将写入当前目录。',
|
||
trailing: _running
|
||
? Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ShadButton.outline(
|
||
onPressed: () => setState(() => _paused = !_paused),
|
||
leading: Icon(
|
||
_paused
|
||
? Icons.play_arrow_rounded
|
||
: Icons.pause_rounded,
|
||
size: 16,
|
||
),
|
||
child: Text(_paused ? '继续' : '暂停'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
ShadButton.destructive(
|
||
onPressed: () =>
|
||
setState(() => _cancelRequested = true),
|
||
leading: const Icon(Icons.stop_rounded, size: 16),
|
||
child: const Text('终止'),
|
||
),
|
||
],
|
||
)
|
||
: ShadButton(
|
||
onPressed: _submit,
|
||
leading: const Icon(Icons.bolt_rounded, size: 16),
|
||
child: const Text('开始秒传'),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
ShadCheckbox(
|
||
value: _createDirectories,
|
||
label: const Text('自动创建目录'),
|
||
onChanged: (value) =>
|
||
setState(() => _createDirectories = value),
|
||
),
|
||
const SizedBox(width: 16),
|
||
ShadCheckbox(
|
||
value: _skipExisting,
|
||
label: const Text('跳过同名文件'),
|
||
onChanged: (value) =>
|
||
setState(() => _skipExisting = value),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
SizedBox(
|
||
height: 220,
|
||
child: ShadInput(
|
||
controller: _json,
|
||
maxLines: null,
|
||
expands: true,
|
||
placeholder: const Text(
|
||
'{"files":[{"path":"Movies/example.mkv","size":123,"gcid":"..."}]}',
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (_running) ...[const SizedBox(height: 12), const ShadProgress()],
|
||
if (_result.isNotEmpty) ...[
|
||
const SizedBox(height: 12),
|
||
Text(_result, style: TextStyle(color: cs.mutedForeground)),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ToolSection extends StatelessWidget {
|
||
final String title;
|
||
final String description;
|
||
final Widget child;
|
||
final Widget? trailing;
|
||
|
||
const _ToolSection({
|
||
required this.title,
|
||
required this.description,
|
||
required this.child,
|
||
this.trailing,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: cs.card,
|
||
border: Border.all(color: cs.border),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w700,
|
||
color: cs.foreground,
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
description,
|
||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
?trailing,
|
||
],
|
||
),
|
||
child,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CategoryManagementTool extends StatefulWidget {
|
||
const _CategoryManagementTool();
|
||
|
||
@override
|
||
State<_CategoryManagementTool> createState() =>
|
||
_CategoryManagementToolState();
|
||
}
|
||
|
||
class _CategoryManagementToolState extends State<_CategoryManagementTool> {
|
||
List<MediaCategoryRule> _rules = [];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final raw = StorageManager.get<dynamic>(StorageKeys.mediaCategoryRules);
|
||
_rules = raw is List
|
||
? raw
|
||
.whereType<Map>()
|
||
.map(
|
||
(value) => MediaCategoryRule.fromJson(
|
||
Map<String, dynamic>.from(value),
|
||
),
|
||
)
|
||
.toList()
|
||
: MediaCategoryRule.presets();
|
||
if (raw is! List) _save();
|
||
}
|
||
|
||
Future<void> _save() => StorageManager.set(
|
||
StorageKeys.mediaCategoryRules,
|
||
_rules.map((rule) => rule.toJson()).toList(),
|
||
);
|
||
|
||
Future<void> _restorePresets() async {
|
||
setState(() => _rules = MediaCategoryRule.presets());
|
||
await _save();
|
||
}
|
||
|
||
Future<void> _editRule([MediaCategoryRule? rule]) async {
|
||
final result = await showShadDialog<MediaCategoryRule>(
|
||
context: context,
|
||
builder: (context) => _CategoryRuleDialog(initialRule: rule),
|
||
);
|
||
if (result == null || !mounted) return;
|
||
setState(() {
|
||
final index = _rules.indexWhere((item) => item.id == result.id);
|
||
if (index == -1) {
|
||
_rules.add(result);
|
||
} else {
|
||
_rules[index] = result;
|
||
}
|
||
});
|
||
await _save();
|
||
}
|
||
|
||
Future<void> _deleteRule(MediaCategoryRule rule) async {
|
||
setState(() => _rules.removeWhere((item) => item.id == rule.id));
|
||
await _save();
|
||
}
|
||
|
||
Future<void> _move(MediaCategoryRule rule, int offset) async {
|
||
final index = _rules.indexWhere((item) => item.id == rule.id);
|
||
final target = index + offset;
|
||
if (index < 0 || target < 0 || target >= _rules.length) return;
|
||
setState(() {
|
||
final moved = _rules.removeAt(index);
|
||
_rules.insert(target, moved);
|
||
});
|
||
await _save();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return ListView(
|
||
padding: const EdgeInsets.all(18),
|
||
children: [
|
||
_ToolSection(
|
||
title: '影视分类规则',
|
||
description: '按 TMDB 原始语言匹配分类;默认分类会接收未匹配到其它规则的资源。',
|
||
trailing: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ShadButton.outline(
|
||
onPressed: _restorePresets,
|
||
leading: const Icon(Icons.restart_alt_rounded, size: 16),
|
||
child: const Text('恢复预设'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
ShadButton(
|
||
onPressed: () => _editRule(),
|
||
leading: const Icon(Icons.add_rounded, size: 16),
|
||
child: const Text('新增分类'),
|
||
),
|
||
],
|
||
),
|
||
child: const SizedBox.shrink(),
|
||
),
|
||
const SizedBox(height: 16),
|
||
_ruleGroup(
|
||
title: '电影分类',
|
||
icon: Icons.movie_rounded,
|
||
kind: TMDBMediaKind.movie,
|
||
color: cs.primary,
|
||
),
|
||
const SizedBox(height: 16),
|
||
_ruleGroup(
|
||
title: '剧集分类',
|
||
icon: Icons.tv_rounded,
|
||
kind: TMDBMediaKind.tv,
|
||
color: cs.foreground,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _ruleGroup({
|
||
required String title,
|
||
required IconData icon,
|
||
required TMDBMediaKind kind,
|
||
required Color color,
|
||
}) {
|
||
final rules = _rules.where((rule) => rule.mediaKind == kind).toList();
|
||
return _ToolSection(
|
||
title: title,
|
||
description: rules.isEmpty ? '尚未配置分类规则。' : '${rules.length} 条分类规则',
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: rules.isEmpty
|
||
? const SizedBox.shrink()
|
||
: Container(
|
||
decoration: BoxDecoration(
|
||
border: Border.all(
|
||
color: ShadTheme.of(context).colorScheme.border,
|
||
),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
for (final rule in rules)
|
||
_CategoryRuleRow(
|
||
rule: rule,
|
||
icon: icon,
|
||
color: color,
|
||
canMoveUp: _rules.indexOf(rule) > 0,
|
||
canMoveDown: _rules.indexOf(rule) < _rules.length - 1,
|
||
onMoveUp: () => _move(rule, -1),
|
||
onMoveDown: () => _move(rule, 1),
|
||
onEdit: () => _editRule(rule),
|
||
onDelete: () => _deleteRule(rule),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CategoryRuleRow extends StatelessWidget {
|
||
final MediaCategoryRule rule;
|
||
final IconData icon;
|
||
final Color color;
|
||
final bool canMoveUp;
|
||
final bool canMoveDown;
|
||
final VoidCallback onMoveUp;
|
||
final VoidCallback onMoveDown;
|
||
final VoidCallback onEdit;
|
||
final VoidCallback onDelete;
|
||
|
||
const _CategoryRuleRow({
|
||
required this.rule,
|
||
required this.icon,
|
||
required this.color,
|
||
required this.canMoveUp,
|
||
required this.canMoveDown,
|
||
required this.onMoveUp,
|
||
required this.onMoveDown,
|
||
required this.onEdit,
|
||
required this.onDelete,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
border: Border(
|
||
bottom: BorderSide(color: cs.border.withValues(alpha: 0.7)),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, color: color, size: 19),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
rule.name,
|
||
style: TextStyle(
|
||
color: cs.foreground,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
rule.isFallback
|
||
? '未设置原始语言的默认分类'
|
||
: '语言 ${rule.languages.join(', ')}',
|
||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (rule.isFallback) const ShadBadge(child: Text('默认')),
|
||
const SizedBox(width: 6),
|
||
_CategoryIconButton(
|
||
tooltip: '上移',
|
||
icon: Icons.arrow_upward_rounded,
|
||
onPressed: canMoveUp ? onMoveUp : null,
|
||
),
|
||
_CategoryIconButton(
|
||
tooltip: '下移',
|
||
icon: Icons.arrow_downward_rounded,
|
||
onPressed: canMoveDown ? onMoveDown : null,
|
||
),
|
||
_CategoryIconButton(
|
||
tooltip: '编辑',
|
||
icon: Icons.edit_outlined,
|
||
onPressed: onEdit,
|
||
),
|
||
_CategoryIconButton(
|
||
tooltip: '删除',
|
||
icon: Icons.delete_outline_rounded,
|
||
onPressed: onDelete,
|
||
destructive: true,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CategoryIconButton extends StatelessWidget {
|
||
final String tooltip;
|
||
final IconData icon;
|
||
final VoidCallback? onPressed;
|
||
final bool destructive;
|
||
|
||
const _CategoryIconButton({
|
||
required this.tooltip,
|
||
required this.icon,
|
||
required this.onPressed,
|
||
this.destructive = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = ShadTheme.of(context).colorScheme;
|
||
return ShadTooltip(
|
||
builder: (_) => Text(tooltip),
|
||
child: ShadButton.ghost(
|
||
size: ShadButtonSize.sm,
|
||
onPressed: onPressed,
|
||
child: Icon(
|
||
icon,
|
||
size: 16,
|
||
color: destructive ? cs.destructive : cs.mutedForeground,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CategoryRuleDialog extends StatefulWidget {
|
||
final MediaCategoryRule? initialRule;
|
||
|
||
const _CategoryRuleDialog({this.initialRule});
|
||
|
||
@override
|
||
State<_CategoryRuleDialog> createState() => _CategoryRuleDialogState();
|
||
}
|
||
|
||
class _CategoryRuleDialogState extends State<_CategoryRuleDialog> {
|
||
late final TextEditingController _name;
|
||
late final TextEditingController _languages;
|
||
late TMDBMediaKind _kind;
|
||
late bool _isFallback;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final rule = widget.initialRule;
|
||
_name = TextEditingController(text: rule?.name ?? '');
|
||
_languages = TextEditingController(text: rule?.languages.join(', ') ?? '');
|
||
_kind = rule?.mediaKind ?? TMDBMediaKind.movie;
|
||
_isFallback = rule?.isFallback ?? false;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_name.dispose();
|
||
_languages.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _save() {
|
||
final name = _name.text.trim();
|
||
if (name.isEmpty) return;
|
||
final languages = _languages.text
|
||
.split(',')
|
||
.map((value) => value.trim().toLowerCase())
|
||
.where((value) => value.isNotEmpty)
|
||
.toSet()
|
||
.toList();
|
||
Navigator.of(context).pop(
|
||
MediaCategoryRule(
|
||
id:
|
||
widget.initialRule?.id ??
|
||
DateTime.now().microsecondsSinceEpoch.toString(),
|
||
name: name,
|
||
mediaKind: _kind,
|
||
languages: languages,
|
||
isFallback: _isFallback,
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ShadDialog(
|
||
title: Text(widget.initialRule == null ? '新增分类' : '编辑分类'),
|
||
actions: [
|
||
ShadButton.outline(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('取消'),
|
||
),
|
||
ShadButton(onPressed: _save, child: const Text('保存')),
|
||
],
|
||
child: SizedBox(
|
||
width: 400,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ShadInput(controller: _name, placeholder: const Text('分类名称')),
|
||
const SizedBox(height: 10),
|
||
ShadSelect<TMDBMediaKind>(
|
||
initialValue: _kind,
|
||
selectedOptionBuilder: (context, value) => Text(value.title),
|
||
options: const [
|
||
ShadOption(value: TMDBMediaKind.movie, child: Text('电影')),
|
||
ShadOption(value: TMDBMediaKind.tv, child: Text('剧集')),
|
||
],
|
||
onChanged: (value) {
|
||
if (value != null) setState(() => _kind = value);
|
||
},
|
||
),
|
||
const SizedBox(height: 10),
|
||
ShadInput(
|
||
controller: _languages,
|
||
enabled: !_isFallback,
|
||
placeholder: const Text('原始语言代码,以英文逗号分隔,例如 zh, en'),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: ShadCheckbox(
|
||
value: _isFallback,
|
||
label: const Text('设为默认分类'),
|
||
sublabel: const Text('在其它分类均未匹配时使用'),
|
||
onChanged: (value) => setState(() => _isFallback = value),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|