From f4306e648ed3ba7db6e33773986900b4e33f6653 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 11:05:29 +0800 Subject: [PATCH] feat: add media categories and folder selection --- lib/core/storage/storage_manager.dart | 1 + lib/models/media_library.dart | 110 ++++++ lib/pages/media_library_page.dart | 502 +++++++++++++++++++++----- lib/pages/workspace_page.dart | 156 ++------ lib/pages/workspace_tools_page.dart | 392 +++++++++++++++++++- 5 files changed, 951 insertions(+), 210 deletions(-) diff --git a/lib/core/storage/storage_manager.dart b/lib/core/storage/storage_manager.dart index 60fe5d9..7433bc4 100644 --- a/lib/core/storage/storage_manager.dart +++ b/lib/core/storage/storage_manager.dart @@ -14,6 +14,7 @@ class StorageKeys { static const String tmdbProxyPort = 'guangya.tmdbProxyPort'; static const String mediaLibraries = 'guangya.mediaLibraries'; static const String mediaLibraryItems = 'guangya.mediaLibraryItems'; + static const String mediaCategoryRules = 'guangya.mediaCategoryRules'; } class StorageManager { diff --git a/lib/models/media_library.dart b/lib/models/media_library.dart index d97526e..b9cdbfb 100644 --- a/lib/models/media_library.dart +++ b/lib/models/media_library.dart @@ -15,6 +15,116 @@ extension TMDBMediaKindX on TMDBMediaKind { } } +class MediaCategoryRule { + final String id; + final String name; + final TMDBMediaKind mediaKind; + final List languages; + final bool isFallback; + + const MediaCategoryRule({ + required this.id, + required this.name, + required this.mediaKind, + this.languages = const [], + this.isFallback = false, + }); + + MediaCategoryRule copyWith({ + String? name, + TMDBMediaKind? mediaKind, + List? languages, + bool? isFallback, + }) { + return MediaCategoryRule( + id: id, + name: name ?? this.name, + mediaKind: mediaKind ?? this.mediaKind, + languages: languages ?? this.languages, + isFallback: isFallback ?? this.isFallback, + ); + } + + factory MediaCategoryRule.fromJson(Map json) { + return MediaCategoryRule( + id: + json['id']?.toString() ?? + DateTime.now().microsecondsSinceEpoch.toString(), + name: json['name']?.toString() ?? '未命名分类', + mediaKind: TMDBMediaKind.values.firstWhere( + (kind) => kind.name == json['mediaKind']?.toString(), + orElse: () => TMDBMediaKind.movie, + ), + languages: + (json['languages'] as List?) + ?.map((value) => value.toString().trim()) + .where((value) => value.isNotEmpty) + .toList() ?? + const [], + isFallback: json['isFallback'] == true, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'mediaKind': mediaKind.name, + 'languages': languages, + 'isFallback': isFallback, + }; + + static List presets() => const [ + MediaCategoryRule( + id: 'preset-movie-cn', + name: '国产电影', + mediaKind: TMDBMediaKind.movie, + languages: ['zh', 'cn', 'yue'], + ), + MediaCategoryRule( + id: 'preset-movie-jpkr', + name: '日韩电影', + mediaKind: TMDBMediaKind.movie, + languages: ['ja', 'ko', 'th'], + ), + MediaCategoryRule( + id: 'preset-movie-west', + name: '欧美电影', + mediaKind: TMDBMediaKind.movie, + languages: ['en'], + ), + MediaCategoryRule( + id: 'preset-movie-other', + name: '其他电影', + mediaKind: TMDBMediaKind.movie, + isFallback: true, + ), + MediaCategoryRule( + id: 'preset-tv-cn', + name: '国产剧集', + mediaKind: TMDBMediaKind.tv, + languages: ['zh', 'cn', 'yue'], + ), + MediaCategoryRule( + id: 'preset-tv-jpkr', + name: '日韩剧集', + mediaKind: TMDBMediaKind.tv, + languages: ['ja', 'ko', 'th'], + ), + MediaCategoryRule( + id: 'preset-tv-west', + name: '欧美剧集', + mediaKind: TMDBMediaKind.tv, + languages: ['en'], + ), + MediaCategoryRule( + id: 'preset-tv-other', + name: '其他剧集', + mediaKind: TMDBMediaKind.tv, + isFallback: true, + ), + ]; +} + enum MediaLibraryKind { movies, series, mixed } extension MediaLibraryKindX on MediaLibraryKind { diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 85a6e9d..c041f42 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -3,6 +3,7 @@ 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/media_library.dart'; import '../providers/auth_provider.dart'; import '../providers/file_provider.dart'; @@ -498,103 +499,19 @@ class _MediaLibraryPageState extends ConsumerState { static void _showCreateLibraryDialog(BuildContext context, WidgetRef ref) { final fileState = ref.read(fileProvider); - final currentRootID = fileState.folderPath.isEmpty - ? null - : fileState.folderPath.last.id; final currentPath = fileState.folderPath.isEmpty ? '云盘根目录' : fileState.folderPath.map((file) => file.name).join(' / '); - final nameController = TextEditingController( - text: fileState.folderPath.isEmpty - ? '我的影视库' - : fileState.folderPath.last.name, - ); - final minSizeController = TextEditingController(text: '50'); - var kind = MediaLibraryKind.mixed; - var recursive = true; - showShadDialog( context: context, - builder: (ctx) => StatefulBuilder( - builder: (context, setDialogState) => ShadDialog( - title: const Text('创建媒体库'), - description: Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text('来源:$currentPath'), - ), - actions: [ - ShadButton.outline( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('取消'), - ), - ShadButton( - onPressed: () { - ref - .read(mediaLibraryProvider.notifier) - .createLibrary( - name: nameController.text, - rootID: currentRootID, - rootPath: currentPath, - kind: kind, - recursive: recursive, - minimumSizeMB: - int.tryParse(minSizeController.text.trim()) ?? 50, - ); - Navigator.of(ctx).pop(); - }, - child: const Text('创建'), - ), - ], - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ShadInput( - controller: nameController, - placeholder: const Text('媒体库名称'), - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: ShadSelect( - initialValue: kind, - placeholder: const Text('媒体类型'), - selectedOptionBuilder: (context, value) => - Text(value.title), - options: [ - for (final value in MediaLibraryKind.values) - ShadOption(value: value, child: Text(value.title)), - ], - onChanged: (value) { - if (value != null) { - setDialogState(() => kind = value); - } - }, - ), - ), - const SizedBox(width: 10), - SizedBox( - width: 120, - child: ShadInput( - controller: minSizeController, - keyboardType: TextInputType.number, - placeholder: const Text('最小 MB'), - ), - ), - ], - ), - const SizedBox(height: 12), - Align( - alignment: Alignment.centerLeft, - child: ShadCheckbox( - value: recursive, - label: const Text('递归扫描子目录'), - onChanged: (value) => setDialogState(() => recursive = value), - ), - ), - ], - ), - ), + builder: (ctx) => _CreateMediaLibraryDialog( + initialRootID: fileState.folderPath.isEmpty + ? null + : fileState.folderPath.last.id, + initialPath: currentPath, + initialName: fileState.folderPath.isEmpty + ? '我的影视库' + : fileState.folderPath.last.name, ), ); } @@ -644,6 +561,407 @@ class _MediaLibraryPageState extends ConsumerState { } } +class _CreateMediaLibraryDialog extends ConsumerStatefulWidget { + final String? initialRootID; + final String initialPath; + final String initialName; + + const _CreateMediaLibraryDialog({ + required this.initialRootID, + required this.initialPath, + required this.initialName, + }); + + @override + ConsumerState<_CreateMediaLibraryDialog> createState() => + _CreateMediaLibraryDialogState(); +} + +class _CreateMediaLibraryDialogState + extends ConsumerState<_CreateMediaLibraryDialog> { + late final TextEditingController _nameController; + final _minSizeController = TextEditingController(text: '50'); + MediaLibraryKind _kind = MediaLibraryKind.mixed; + bool _recursive = true; + bool _isBrowsing = false; + bool _isLoadingFolders = false; + String? _folderError; + late String? _rootID; + late String _rootPath; + final _browserPath = []; + var _folders = []; + + @override + void initState() { + super.initState(); + _rootID = widget.initialRootID; + _rootPath = widget.initialPath; + _nameController = TextEditingController(text: widget.initialName); + } + + @override + void dispose() { + _nameController.dispose(); + _minSizeController.dispose(); + super.dispose(); + } + + String get _browserLocation => _browserPath.isEmpty + ? '云盘根目录' + : _browserPath.map((folder) => folder.name).join(' / '); + + String? get _browserFolderID => + _browserPath.isEmpty ? null : _browserPath.last.id; + + Future _startBrowsing() async { + setState(() { + _isBrowsing = true; + _browserPath.clear(); + _folders = []; + }); + await _loadFolders(); + } + + Future _loadFolders() async { + setState(() { + _isLoadingFolders = true; + _folderError = null; + }); + try { + final response = await ref + .read(authProvider.notifier) + .api + .fsFiles(parentID: _browserFolderID, pageSize: 1000); + if (mounted) { + setState(() { + _folders = + _extractFiles(response).where((file) => file.isDirectory).toList() + ..sort( + (a, b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + }); + } + } catch (error) { + if (mounted) setState(() => _folderError = error.toString()); + } finally { + if (mounted) setState(() => _isLoadingFolders = false); + } + } + + List _extractFiles(Map value) { + final files = []; + final ids = {}; + void visit(dynamic node) { + if (node is Map) { + try { + final file = CloudFile.fromJson(Map.from(node)); + if (ids.add(file.id)) files.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 files; + } + + void _useBrowserFolder() { + setState(() { + _rootID = _browserFolderID; + _rootPath = _browserLocation; + if (_nameController.text.trim().isEmpty || + _nameController.text == widget.initialName) { + _nameController.text = _browserPath.isEmpty + ? '我的影视库' + : _browserPath.last.name; + } + _isBrowsing = false; + }); + } + + void _create(BuildContext context) { + ref + .read(mediaLibraryProvider.notifier) + .createLibrary( + name: _nameController.text, + rootID: _rootID, + rootPath: _rootPath, + kind: _kind, + recursive: _recursive, + minimumSizeMB: int.tryParse(_minSizeController.text.trim()) ?? 50, + ); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return ShadDialog( + title: Text(_isBrowsing ? '选择云盘文件夹' : '创建媒体库'), + description: Text( + _isBrowsing ? '进入目标目录后,选择该目录作为媒体库来源。' : '媒体库会从指定目录扫描视频文件。', + ), + actions: _isBrowsing + ? [ + ShadButton.outline( + onPressed: () => setState(() => _isBrowsing = false), + child: const Text('返回设置'), + ), + ShadButton( + onPressed: _useBrowserFolder, + leading: const Icon(Icons.check_rounded, size: 16), + child: const Text('使用此目录'), + ), + ] + : [ + ShadButton.outline( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ShadButton( + onPressed: () => _create(context), + leading: const Icon(Icons.add_rounded, size: 16), + child: const Text('创建媒体库'), + ), + ], + child: _isBrowsing ? _folderBrowser(cs) : _form(cs), + ); + } + + Widget _form(ShadColorScheme cs) { + return SizedBox( + width: 520, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.muted, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.border), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(7), + ), + child: Icon(Icons.folder_rounded, color: cs.primary), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '媒体来源', + style: TextStyle( + fontSize: 12, + color: cs.mutedForeground, + ), + ), + const SizedBox(height: 2), + Text( + _rootPath, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), + ], + ), + ), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _startBrowsing, + leading: const Icon(Icons.folder_open_rounded, size: 15), + child: const Text('浏览'), + ), + ], + ), + ), + const SizedBox(height: 14), + ShadInput( + controller: _nameController, + placeholder: const Text('媒体库名称'), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: ShadSelect( + initialValue: _kind, + placeholder: const Text('媒体类型'), + selectedOptionBuilder: (context, value) => Text(value.title), + options: [ + for (final value in MediaLibraryKind.values) + ShadOption(value: value, child: Text(value.title)), + ], + onChanged: (value) { + if (value != null) setState(() => _kind = value); + }, + ), + ), + const SizedBox(width: 10), + SizedBox( + width: 132, + child: ShadInput( + controller: _minSizeController, + keyboardType: TextInputType.number, + placeholder: const Text('最小 MB'), + ), + ), + ], + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: ShadCheckbox( + value: _recursive, + label: const Text('递归扫描所有子目录'), + sublabel: const Text('关闭后仅扫描当前目录中的视频文件'), + onChanged: (value) => setState(() => _recursive = value), + ), + ), + ], + ), + ); + } + + Widget _folderBrowser(ShadColorScheme cs) { + return SizedBox( + width: 560, + height: 390, + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: cs.muted, + borderRadius: BorderRadius.circular(7), + ), + child: Row( + children: [ + ShadTooltip( + builder: (_) => const Text('返回上级目录'), + child: ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: _browserPath.isEmpty + ? null + : () { + setState(() => _browserPath.removeLast()); + _loadFolders(); + }, + child: const Icon(Icons.arrow_back_rounded, size: 16), + ), + ), + const SizedBox(width: 6), + Icon(Icons.folder_rounded, size: 16, color: cs.primary), + const SizedBox(width: 6), + Expanded( + child: Text( + _browserLocation, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: cs.foreground), + ), + ), + ShadTooltip( + builder: (_) => const Text('刷新目录'), + child: ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: _isLoadingFolders ? null : _loadFolders, + child: const Icon(Icons.refresh_rounded, size: 16), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Expanded( + child: _isLoadingFolders + ? const Center( + child: SizedBox(width: 220, child: ShadProgress()), + ) + : _folderError != null + ? Center( + child: Text( + _folderError!, + textAlign: TextAlign.center, + style: TextStyle(color: cs.destructive), + ), + ) + : _folders.isEmpty + ? Center( + child: Text( + '此目录没有子文件夹', + style: TextStyle(color: cs.mutedForeground), + ), + ) + : ListView.separated( + itemCount: _folders.length, + separatorBuilder: (_, _) => + Divider(height: 1, color: cs.border), + itemBuilder: (context, index) { + final folder = _folders[index]; + return InkWell( + onTap: () { + setState(() => _browserPath.add(folder)); + _loadFolders(); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 11, + ), + child: Row( + children: [ + Icon( + Icons.folder_rounded, + size: 19, + color: cs.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + folder.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + Icons.chevron_right_rounded, + size: 18, + color: cs.mutedForeground, + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + class _LibraryRow extends StatelessWidget { final MediaLibraryDefinition library; final bool selected; diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index 2275b16..e14ba80 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -576,7 +576,7 @@ class _MediaSidebar extends ConsumerWidget { icon: Icons.category_rounded, label: '分类管理', selected: false, - onTap: () => onTool(WorkspaceTool.tmdb), + onTap: () => onTool(WorkspaceTool.categories), ), ], ), @@ -782,7 +782,6 @@ class _CloudWorkspaceState extends ConsumerState<_CloudWorkspace> { state: state, ), ), - _CloudStatusBar(state: state), ], ), ), @@ -909,67 +908,12 @@ class _ToolbarSegment extends StatelessWidget { @override Widget build(BuildContext context) { - return OS26Glass( - radius: 10, - opacity: 0.36, - padding: const EdgeInsets.all(2), - child: Row( - children: [ - _ToolbarSegmentButton( - icon: Icons.view_agenda_rounded, - selected: value == _PaneLayoutMode.single, - tooltip: '单面板', - onTap: () => onChanged(_PaneLayoutMode.single), - ), - _ToolbarSegmentButton( - icon: Icons.view_column_rounded, - selected: value == _PaneLayoutMode.dual, - tooltip: '双面板', - onTap: () => onChanged(_PaneLayoutMode.dual), - ), - ], - ), - ); - } -} - -class _ToolbarSegmentButton extends StatelessWidget { - final IconData icon; - final bool selected; - final String tooltip; - final VoidCallback onTap; - - const _ToolbarSegmentButton({ - required this.icon, - required this.selected, - required this.tooltip, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final cs = ShadTheme.of(context).colorScheme; - return ShadTooltip( - builder: (_) => Text(tooltip), - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: onTap, - child: Container( - width: 32, - height: 28, - decoration: BoxDecoration( - color: selected - ? Colors.white.withValues(alpha: 0.68) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - icon, - size: 17, - color: selected ? cs.primary : cs.mutedForeground, - ), - ), - ), + final isDual = value == _PaneLayoutMode.dual; + return _ToolbarButton( + icon: isDual ? Icons.view_agenda_rounded : Icons.view_column_rounded, + label: isDual ? '切换单面板' : '切换双面板', + onTap: () => + onChanged(isDual ? _PaneLayoutMode.single : _PaneLayoutMode.dual), ); } } @@ -1068,6 +1012,8 @@ class _PrimaryFilePane extends ConsumerWidget { currentPage: state.currentPage, pageSize: state.pageSize, totalPages: state.totalPages, + fileCount: files.where((file) => !file.isDirectory).length, + folderCount: files.where((file) => file.isDirectory).length, onPreviousPage: state.currentPage == 0 ? null : notifier.prevPage, onNextPage: state.currentPage >= state.totalPages - 1 ? null @@ -1238,6 +1184,8 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { currentPage: _page, pageSize: _pageSize, totalPages: _totalPages, + fileCount: _files.where((file) => !file.isDirectory).length, + folderCount: _files.where((file) => file.isDirectory).length, onPreviousPage: _page == 0 ? null : () { @@ -1403,6 +1351,8 @@ class _PanePagination extends StatelessWidget { final int currentPage; final int pageSize; final int totalPages; + final int fileCount; + final int folderCount; final VoidCallback? onPreviousPage; final VoidCallback? onNextPage; final ValueChanged? onPageSizeChanged; @@ -1411,6 +1361,8 @@ class _PanePagination extends StatelessWidget { required this.currentPage, required this.pageSize, required this.totalPages, + required this.fileCount, + required this.folderCount, this.onPreviousPage, this.onNextPage, this.onPageSizeChanged, @@ -1429,6 +1381,24 @@ class _PanePagination extends StatelessWidget { ), child: Row( children: [ + Icon( + Icons.insert_drive_file_rounded, + size: 14, + color: cs.mutedForeground, + ), + const SizedBox(width: 5), + Text( + '本页文件 $fileCount', + style: TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + const SizedBox(width: 12), + Icon(Icons.folder_rounded, size: 14, color: cs.mutedForeground), + const SizedBox(width: 5), + Text( + '本页文件夹 $folderCount', + style: TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + const SizedBox(width: 14), Text( '第 ${currentPage + 1} / ${totalPages.clamp(1, 1 << 31)} 页', style: TextStyle(fontSize: 11, color: cs.mutedForeground), @@ -1518,6 +1488,8 @@ class _FilePaneFrame extends StatelessWidget { final int currentPage; final int pageSize; final int totalPages; + final int fileCount; + final int folderCount; final VoidCallback? onPreviousPage; final VoidCallback? onNextPage; final ValueChanged? onPageSizeChanged; @@ -1538,6 +1510,8 @@ class _FilePaneFrame extends StatelessWidget { this.currentPage = 0, this.pageSize = 50, this.totalPages = 1, + this.fileCount = 0, + this.folderCount = 0, this.onPreviousPage, this.onNextPage, this.onPageSizeChanged, @@ -1617,6 +1591,8 @@ class _FilePaneFrame extends StatelessWidget { currentPage: currentPage, pageSize: pageSize, totalPages: totalPages, + fileCount: fileCount, + folderCount: folderCount, onPreviousPage: onPreviousPage, onNextPage: onNextPage, onPageSizeChanged: onPageSizeChanged, @@ -1826,57 +1802,3 @@ class _FilePaneHeader extends StatelessWidget { ); } } - -class _CloudStatusBar extends StatelessWidget { - final FileState state; - - const _CloudStatusBar({required this.state}); - - @override - Widget build(BuildContext context) { - final cs = ShadTheme.of(context).colorScheme; - return Container( - height: 40, - margin: const EdgeInsets.only(top: 10), - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.38), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - Icons.insert_drive_file_rounded, - size: 15, - color: cs.mutedForeground, - ), - const SizedBox(width: 6), - Text( - '本页文件 ${state.files.where((file) => !file.isDirectory).length}', - style: TextStyle(fontSize: 12, color: cs.mutedForeground), - ), - const SizedBox(width: 14), - Icon(Icons.folder_rounded, size: 15, color: cs.mutedForeground), - const SizedBox(width: 6), - Text( - '本页文件夹 ${state.files.where((file) => file.isDirectory).length}', - style: TextStyle(fontSize: 12, color: cs.mutedForeground), - ), - const Spacer(), - if (state.errorMessage != null) - Text( - state.errorMessage!, - style: TextStyle(fontSize: 12, color: cs.destructive), - overflow: TextOverflow.ellipsis, - ) - else if (state.statusMessage != null) - Text( - state.statusMessage!, - style: TextStyle(fontSize: 12, color: cs.primary), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ); - } -} diff --git a/lib/pages/workspace_tools_page.dart b/lib/pages/workspace_tools_page.dart index a263e1b..c6fd9e8 100644 --- a/lib/pages/workspace_tools_page.dart +++ b/lib/pages/workspace_tools_page.dart @@ -4,12 +4,14 @@ 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/media_library.dart'; import '../providers/auth_provider.dart'; import '../providers/file_provider.dart'; import 'media_library_page.dart'; -enum WorkspaceTool { scan, rename, fastTransfer, tmdb } +enum WorkspaceTool { scan, rename, fastTransfer, tmdb, categories } extension WorkspaceToolDetails on WorkspaceTool { String get title { @@ -22,6 +24,8 @@ extension WorkspaceToolDetails on WorkspaceTool { return '秒传工具'; case WorkspaceTool.tmdb: return 'TMDB 整理'; + case WorkspaceTool.categories: + return '分类管理'; } } @@ -35,6 +39,8 @@ extension WorkspaceToolDetails on WorkspaceTool { return Icons.bolt_rounded; case WorkspaceTool.tmdb: return Icons.auto_fix_high_rounded; + case WorkspaceTool.categories: + return Icons.grid_view_rounded; } } } @@ -56,6 +62,7 @@ class WorkspaceToolsPage extends StatelessWidget { WorkspaceTool.rename => const _BatchRenameTool(), WorkspaceTool.fastTransfer => const _FastTransferTool(), WorkspaceTool.tmdb => const MediaLibraryPage(showLibrarySidebar: true), + WorkspaceTool.categories => const _CategoryManagementTool(), }; return Column( children: [ @@ -642,3 +649,386 @@ class _ToolSection extends StatelessWidget { ); } } + +class _CategoryManagementTool extends StatefulWidget { + const _CategoryManagementTool(); + + @override + State<_CategoryManagementTool> createState() => + _CategoryManagementToolState(); +} + +class _CategoryManagementToolState extends State<_CategoryManagementTool> { + List _rules = []; + + @override + void initState() { + super.initState(); + final raw = StorageManager.get(StorageKeys.mediaCategoryRules); + _rules = raw is List + ? raw + .whereType() + .map( + (value) => MediaCategoryRule.fromJson( + Map.from(value), + ), + ) + .toList() + : MediaCategoryRule.presets(); + if (raw is! List) _save(); + } + + Future _save() => StorageManager.set( + StorageKeys.mediaCategoryRules, + _rules.map((rule) => rule.toJson()).toList(), + ); + + Future _restorePresets() async { + setState(() => _rules = MediaCategoryRule.presets()); + await _save(); + } + + Future _editRule([MediaCategoryRule? rule]) async { + final result = await showShadDialog( + 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 _deleteRule(MediaCategoryRule rule) async { + setState(() => _rules.removeWhere((item) => item.id == rule.id)); + await _save(); + } + + Future _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( + 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), + ), + ), + ], + ), + ), + ); + } +}