From b975873f3b09c145a71cbbd0229c276e1c370af6 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 10:33:28 +0800 Subject: [PATCH] feat: improve workspace navigation and shadcn UI --- lib/app/app.dart | 4 +- lib/pages/media_library_page.dart | 181 ++++---- lib/pages/workspace_page.dart | 536 ++++++++++++++++------ lib/providers/file_provider.dart | 18 +- lib/providers/media_library_provider.dart | 11 + lib/widgets/sort_menu.dart | 90 ++-- 6 files changed, 553 insertions(+), 287 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index c23b470..76f5c70 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -39,7 +39,9 @@ class GuangyaApp extends ConsumerWidget { darkTheme: darkTheme, themeMode: themeState.themeMode, home: auth.isLoading - ? const Scaffold(body: Center(child: CircularProgressIndicator())) + ? const Scaffold( + body: Center(child: SizedBox(width: 220, child: ShadProgress())), + ) : auth.isSignedIn ? const WorkspacePage() : const LoginPage(), diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index b91283f..85a6e9d 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -545,61 +545,54 @@ class _MediaLibraryPageState extends ConsumerState { child: const Text('创建'), ), ], - child: Material( - type: MaterialType.transparency, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ShadInput( - controller: nameController, - placeholder: const Text('媒体库名称'), - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: DropdownButtonFormField( - initialValue: kind, - decoration: const InputDecoration( - labelText: '类型', - border: OutlineInputBorder(), - ), - items: MediaLibraryKind.values - .map( - (value) => DropdownMenuItem( - value: value, - child: Text(value.title), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - setDialogState(() => kind = value); - } - }, - ), + 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, - placeholder: const Text('最小 MB'), - ), + ), + const SizedBox(width: 10), + SizedBox( + width: 120, + child: ShadInput( + controller: minSizeController, + keyboardType: TextInputType.number, + placeholder: const Text('最小 MB'), ), - ], - ), - const SizedBox(height: 10), - CheckboxListTile( - contentPadding: EdgeInsets.zero, + ), + ], + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: ShadCheckbox( value: recursive, - onChanged: (value) => - setDialogState(() => recursive = value ?? true), - title: const Text('递归扫描子目录'), - controlAffinity: ListTileControlAffinity.leading, + label: const Text('递归扫描子目录'), + onChanged: (value) => setDialogState(() => recursive = value), ), - ], - ), + ), + ], ), ), ), @@ -676,44 +669,56 @@ class _LibraryRow extends StatelessWidget { borderRadius: BorderRadius.circular(8), border: Border.all(color: selected ? cs.primary : cs.border), ), - child: ListTile( - dense: true, - leading: Icon( - Icons.video_library_rounded, - size: 20, - color: selected ? cs.primary : cs.mutedForeground, - ), - title: Text( - library.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - color: cs.foreground, - ), - ), - subtitle: Text( - '${library.kind.title} · ${library.rootPath}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, color: cs.mutedForeground), - ), - trailing: PopupMenuButton( - tooltip: '更多', - icon: Icon( - Icons.more_horiz_rounded, - size: 18, - color: cs.mutedForeground, - ), - onSelected: (value) { - if (value == 'delete') onDelete(); - }, - itemBuilder: (_) => const [ - PopupMenuItem(value: 'delete', child: Text('删除')), - ], - ), + child: InkWell( + borderRadius: BorderRadius.circular(8), onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 6, 8), + child: Row( + children: [ + Icon( + Icons.video_library_rounded, + size: 20, + color: selected ? cs.primary : cs.mutedForeground, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + library.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + color: cs.foreground, + ), + ), + const SizedBox(height: 2), + Text( + '${library.kind.title} · ${library.rootPath}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + ], + ), + ), + ShadTooltip( + builder: (_) => const Text('删除媒体库'), + child: ShadButton.destructive( + size: ShadButtonSize.sm, + onPressed: onDelete, + child: const Icon(Icons.delete_outline_rounded, size: 15), + ), + ), + ], + ), + ), ), ); } diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index da75d57..f4221f9 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -14,12 +14,13 @@ import '../providers/auth_provider.dart'; import '../providers/file_provider.dart'; import '../providers/media_library_provider.dart'; import '../widgets/breadcrumb_bar.dart'; -import '../widgets/file_icon.dart'; import '../widgets/file_list_tile.dart'; import '../widgets/side_panel.dart'; import '../widgets/sort_menu.dart'; import 'media_library_page.dart'; +import 'search_results_page.dart'; import 'settings_page.dart'; +import 'workspace_tools_page.dart'; enum WorkspaceMode { cloud, media } @@ -47,8 +48,9 @@ class _WorkspacePageState extends ConsumerState { WorkspaceMode _mode = WorkspaceMode.cloud; bool _isSidePanelOpen = false; bool _searchOpen = false; - String _cloudSearchQuery = ''; - String _mediaSearchQuery = ''; + String? _fileSearchQuery; + String? _mediaSearchQuery; + WorkspaceTool? _activeTool; @override void dispose() { @@ -77,10 +79,12 @@ class _WorkspacePageState extends ConsumerState { onSettings: () => _showSettings(context), onSignOut: () => ref.read(authProvider.notifier).signOut(), + onTool: (tool) => setState(() => _activeTool = tool), ) : _MediaSidebar( onCreate: () => MediaLibraryPage.showCreateDialog(context, ref), + onTool: (tool) => setState(() => _activeTool = tool), ), const SizedBox(width: 16), Expanded( @@ -102,24 +106,20 @@ class _WorkspacePageState extends ConsumerState { searchOpen: _searchOpen, onSearch: (value) { final query = value.trim(); + if (query.isEmpty) return; if (_mode == WorkspaceMode.media) { setState(() => _mediaSearchQuery = query); ref .read(mediaLibraryProvider.notifier) .setSearchQuery(query); } else { - setState(() => _cloudSearchQuery = query); + setState(() => _fileSearchQuery = query); } }, onToggleSearch: () { setState(() => _searchOpen = !_searchOpen); if (!_searchOpen) { _searchController.clear(); - _cloudSearchQuery = ''; - _mediaSearchQuery = ''; - ref - .read(mediaLibraryProvider.notifier) - .setSearchQuery(''); } else { WidgetsBinding.instance.addPostFrameCallback((_) { _searchFocusNode.requestFocus(); @@ -129,10 +129,49 @@ class _WorkspacePageState extends ConsumerState { ), const SizedBox(height: 14), Expanded( - child: _mode == WorkspaceMode.cloud + child: _activeTool != null + ? OS26Glass( + radius: 18, + opacity: 0.42, + padding: EdgeInsets.zero, + child: WorkspaceToolsPage( + tool: _activeTool!, + onClose: () => + setState(() => _activeTool = null), + ), + ) + : _fileSearchQuery != null + ? OS26Glass( + radius: 18, + opacity: 0.42, + padding: EdgeInsets.zero, + child: FileSearchResultsPage( + query: _fileSearchQuery!, + onClose: () => setState(() { + _fileSearchQuery = null; + _searchController.clear(); + }), + ), + ) + : _mediaSearchQuery != null + ? OS26Glass( + radius: 18, + opacity: 0.42, + padding: EdgeInsets.zero, + child: MediaSearchResultsPage( + query: _mediaSearchQuery!, + onClose: () => setState(() { + _mediaSearchQuery = null; + _searchController.clear(); + ref + .read(mediaLibraryProvider.notifier) + .setSearchQuery(''); + }), + ), + ) + : _mode == WorkspaceMode.cloud ? _CloudWorkspace( state: fp, - searchQuery: _cloudSearchQuery, sidePanelOpen: _isSidePanelOpen, onToggleSidePanel: () => setState( () => _isSidePanelOpen = !_isSidePanelOpen, @@ -144,9 +183,6 @@ class _WorkspacePageState extends ConsumerState { padding: EdgeInsets.zero, child: MediaLibraryPage( showLibrarySidebar: false, - searchTitle: _mediaSearchQuery.isEmpty - ? null - : '影视资源搜索:$_mediaSearchQuery', ), ), ), @@ -230,7 +266,11 @@ class _TopBar extends StatelessWidget { child: searchOpen ? Row( children: [ - Icon(Icons.search_rounded, size: 18, color: cs.foreground), + Icon( + Icons.search_rounded, + size: 18, + color: cs.foreground, + ), const SizedBox(width: 8), Expanded( child: TextField( @@ -338,12 +378,14 @@ class _CloudSidebar extends StatelessWidget { final ValueChanged onSection; final VoidCallback onSettings; final VoidCallback onSignOut; + final ValueChanged onTool; const _CloudSidebar({ required this.state, required this.onSection, required this.onSettings, required this.onSignOut, + required this.onTool, }); @override @@ -381,19 +423,19 @@ class _CloudSidebar extends StatelessWidget { icon: Icons.manage_search_rounded, label: '文件扫描与清理', selected: false, - onTap: () {}, + onTap: () => onTool(WorkspaceTool.scan), ), _SidebarTile( icon: Icons.text_fields_rounded, label: '批量重命名', selected: false, - onTap: () {}, + onTap: () => onTool(WorkspaceTool.rename), ), _SidebarTile( icon: Icons.bolt_rounded, label: '秒传工具', selected: false, - onTap: () {}, + onTap: () => onTool(WorkspaceTool.fastTransfer), ), ], ), @@ -446,8 +488,9 @@ class _CloudSidebar extends StatelessWidget { class _MediaSidebar extends ConsumerWidget { final VoidCallback onCreate; + final ValueChanged onTool; - const _MediaSidebar({required this.onCreate}); + const _MediaSidebar({required this.onCreate, required this.onTool}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -472,14 +515,14 @@ class _MediaSidebar extends ConsumerWidget { icon: Icons.home_rounded, label: '首页', selected: true, - onTap: () {}, + onTap: () => onTool(WorkspaceTool.tmdb), ), _SidebarTile( icon: Icons.movie_creation_rounded, label: '电影', count: state.statistics.movies, selected: false, - onTap: () {}, + onTap: () => onTool(WorkspaceTool.tmdb), ), _SidebarTile( icon: Icons.live_tv_rounded, @@ -527,13 +570,13 @@ class _MediaSidebar extends ConsumerWidget { icon: Icons.auto_fix_high_rounded, label: 'TMDB 整理', selected: false, - onTap: () {}, + onTap: () => onTool(WorkspaceTool.tmdb), ), _SidebarTile( icon: Icons.category_rounded, label: '分类管理', selected: false, - onTap: () {}, + onTap: () => onTool(WorkspaceTool.tmdb), ), ], ), @@ -682,13 +725,11 @@ class _SidebarTile extends StatelessWidget { class _CloudWorkspace extends ConsumerStatefulWidget { final FileState state; - final String searchQuery; final bool sidePanelOpen; final VoidCallback onToggleSidePanel; const _CloudWorkspace({ required this.state, - required this.searchQuery, required this.sidePanelOpen, required this.onToggleSidePanel, }); @@ -703,14 +744,6 @@ class _CloudWorkspaceState extends ConsumerState<_CloudWorkspace> { @override Widget build(BuildContext context) { final state = widget.state; - final query = widget.searchQuery.trim().toLowerCase(); - final searchFiles = query.isEmpty - ? state.files - : state.files - .where((file) => - file.name.toLowerCase().contains(query) || - file.cloudPath.toLowerCase().contains(query)) - .toList(); return Row( children: [ Expanded( @@ -729,27 +762,21 @@ class _CloudWorkspaceState extends ConsumerState<_CloudWorkspace> { ), const SizedBox(height: 12), Expanded( - child: query.isNotEmpty - ? _PrimaryFilePane( - title: '文件搜索:${widget.searchQuery}', - state: state, - overrideFiles: searchFiles, - ) - : state.section == WorkspaceSection.files + child: state.section == WorkspaceSection.files ? _paneMode == _PaneLayoutMode.dual - ? Row( - children: [ - Expanded( - child: _PrimaryFilePane( - title: '左侧面板', - state: state, + ? Row( + children: [ + Expanded( + child: _PrimaryFilePane( + title: '左侧面板', + state: state, + ), ), - ), - const SizedBox(width: 10), - const Expanded(child: _SecondaryFilePane()), - ], - ) - : _PrimaryFilePane(title: '文件列表', state: state) + const SizedBox(width: 10), + const Expanded(child: _SecondaryFilePane()), + ], + ) + : _PrimaryFilePane(title: '文件列表', state: state) : _PrimaryFilePane( title: state.section.label, state: state, @@ -838,7 +865,7 @@ class _CloudToolbar extends ConsumerWidget { } Future _pickAndUpload(WidgetRef ref) async { - final result = await FilePicker.pickFiles(allowMultiple: true); + final result = await FilePicker.pickFiles(); if (result == null) return; final files = result.paths.whereType().map(File.new).toList(); await ref.read(fileProvider.notifier).uploadLocalFiles(files); @@ -980,10 +1007,14 @@ class _ToolbarButton extends StatelessWidget { height: 32, padding: EdgeInsets.symmetric(horizontal: primary ? 10 : 0), decoration: BoxDecoration( - color: primary ? cs.primary : Colors.white.withValues(alpha: 0.46), + color: primary + ? cs.primary + : Colors.white.withValues(alpha: 0.46), borderRadius: BorderRadius.circular(10), border: Border.all( - color: primary ? cs.primary : Colors.white.withValues(alpha: 0.54), + color: primary + ? cs.primary + : Colors.white.withValues(alpha: 0.54), ), ), child: Row( @@ -1017,18 +1048,13 @@ class _ToolbarButton extends StatelessWidget { class _PrimaryFilePane extends ConsumerWidget { final String title; final FileState state; - final List? overrideFiles; - const _PrimaryFilePane({ - required this.title, - required this.state, - this.overrideFiles, - }); + const _PrimaryFilePane({required this.title, required this.state}); @override Widget build(BuildContext context, WidgetRef ref) { final notifier = ref.read(fileProvider.notifier); - final files = overrideFiles ?? state.files; + final files = state.files; return _FilePaneFrame( title: title, itemCount: files.length, @@ -1044,6 +1070,14 @@ class _PrimaryFilePane extends ConsumerWidget { notifier.moveFilesTo(files, parentID: parentID), onUploadLocalFiles: (files, parentID) => notifier.uploadLocalFiles(files, parentID: parentID), + currentPage: state.currentPage, + pageSize: state.pageSize, + totalPages: state.totalPages, + onPreviousPage: state.currentPage == 0 ? null : notifier.prevPage, + onNextPage: state.currentPage >= state.totalPages - 1 + ? null + : notifier.nextPage, + onPageSizeChanged: notifier.setPageSize, child: RefreshIndicator( onRefresh: () => notifier.loadFiles(), child: ListView.builder( @@ -1098,6 +1132,9 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { var _files = []; var _loading = false; String? _error; + var _page = 0; + var _pageSize = 50; + var _totalPages = 1; @override void initState() { @@ -1112,8 +1149,16 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { }); try { final api = ref.read(authProvider.notifier).api; - final result = await api.fsFiles(parentID: parentID, pageSize: 50); - setState(() => _files = _extractFiles(result)); + final result = await api.fsFiles( + parentID: parentID, + page: _page, + pageSize: _pageSize, + ); + final files = _extractFiles(result); + setState(() { + _files = files; + _totalPages = _extractTotalPages(result, files.length); + }); } catch (e) { setState(() => _error = e.toString()); } finally { @@ -1131,7 +1176,10 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { }); try { final api = ref.read(authProvider.notifier).api; - await api.fsMove(files.map((file) => file.id).toList(), parentID: parentID); + await api.fsMove( + files.map((file) => file.id).toList(), + parentID: parentID, + ); await _load(parentID: _currentParentID); await ref.read(fileProvider.notifier).loadFiles(); } catch (e) { @@ -1182,13 +1230,38 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { icon: Icons.arrow_back_rounded, label: '返回', onTap: () { - setState(() => _path.removeLast()); + setState(() { + _path.removeLast(); + _page = 0; + }); _load(parentID: _path.isEmpty ? null : _path.last.id); }, ), const Expanded(child: _FilePaneHeader()), ], ), + currentPage: _page, + pageSize: _pageSize, + totalPages: _totalPages, + onPreviousPage: _page == 0 + ? null + : () { + setState(() => _page -= 1); + _load(parentID: _currentParentID); + }, + onNextPage: _page >= _totalPages - 1 + ? null + : () { + setState(() => _page += 1); + _load(parentID: _currentParentID); + }, + onPageSizeChanged: (size) { + setState(() { + _pageSize = size; + _page = 0; + }); + _load(parentID: _currentParentID); + }, child: RefreshIndicator( onRefresh: () => _load(parentID: _currentParentID), child: ListView.builder( @@ -1201,7 +1274,10 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { file: file, onSelect: () { if (file.isDirectory) { - setState(() => _path.add(file)); + setState(() { + _path.add(file); + _page = 0; + }); _load(parentID: file.id); } else { ref.read(fileProvider.notifier).downloadFile(file); @@ -1209,16 +1285,23 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { }, onOpen: () { if (file.isDirectory) { - setState(() => _path.add(file)); + setState(() { + _path.add(file); + _page = 0; + }); _load(parentID: file.id); } else { ref.read(fileProvider.notifier).downloadFile(file); } }, - onCopy: () => ref.read(fileProvider.notifier).copyToClipboard([file]), - onCut: () => ref.read(fileProvider.notifier).cutToClipboard([file]), - onDownload: () => ref.read(fileProvider.notifier).downloadFile(file), - onDelete: () => ref.read(fileProvider.notifier).deleteFiles([file]), + onCopy: () => + ref.read(fileProvider.notifier).copyToClipboard([file]), + onCut: () => + ref.read(fileProvider.notifier).cutToClipboard([file]), + onDownload: () => + ref.read(fileProvider.notifier).downloadFile(file), + onDelete: () => + ref.read(fileProvider.notifier).deleteFiles([file]), ); return Draggable<_DraggedCloudFiles>( data: _DraggedCloudFiles([file], _PaneIdentity.secondary), @@ -1257,14 +1340,169 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { void _navigateBreadcrumb(int index) { if (index < 0) { - setState(() => _path.clear()); + setState(() { + _path.clear(); + _page = 0; + }); _load(); return; } if (!_path.asMap().containsKey(index)) return; - setState(() => _path.removeRange(index + 1, _path.length)); + setState(() { + _path.removeRange(index + 1, _path.length); + _page = 0; + }); _load(parentID: _path.isEmpty ? null : _path.last.id); } + + int _extractTotalPages(Map json, int itemCount) { + int? find(Map value) { + for (final key in const [ + 'totalPages', + 'pages', + 'pageCount', + 'total', + 'totalCount', + 'count', + ]) { + final raw = value[key]; + final parsed = raw is int ? raw : int.tryParse(raw?.toString() ?? ''); + if (parsed != null && parsed >= 0) return parsed; + } + for (final child in value.values) { + if (child is Map) { + final result = find(Map.from(child)); + if (result != null) return result; + } + } + return null; + } + + final result = find(json); + if (result == null) return itemCount < _pageSize ? 1 : _page + 2; + if (result <= 0) return 1; + final hasExplicitPageCount = + json.containsKey('totalPages') || + json.containsKey('pages') || + json.containsKey('pageCount'); + return hasExplicitPageCount + ? result + : (result / _pageSize).ceil().clamp(1, 1 << 31).toInt(); + } +} + +class _PanePagination extends StatelessWidget { + static const _pageSizes = [ + 10, + 20, + 50, + 100, + 200, + 500, + 1000, + 2000, + 5000, + 10000, + ]; + + final int currentPage; + final int pageSize; + final int totalPages; + final VoidCallback? onPreviousPage; + final VoidCallback? onNextPage; + final ValueChanged? onPageSizeChanged; + + const _PanePagination({ + required this.currentPage, + required this.pageSize, + required this.totalPages, + this.onPreviousPage, + this.onNextPage, + this.onPageSizeChanged, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Container( + height: 38, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: cs.border.withValues(alpha: 0.62)), + ), + ), + child: Row( + children: [ + Text( + '第 ${currentPage + 1} / ${totalPages.clamp(1, 1 << 31)} 页', + style: TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + const Spacer(), + ShadSelect( + initialValue: pageSize, + enabled: onPageSizeChanged != null, + minWidth: 80, + selectedOptionBuilder: (context, value) => Text('$value / 页'), + options: [ + for (final size in _pageSizes) + ShadOption(value: size, child: Text('$size / 页')), + ], + onChanged: (value) { + if (value != null) onPageSizeChanged?.call(value); + }, + ), + const SizedBox(width: 6), + _PaneIconButton( + icon: Icons.chevron_left_rounded, + tooltip: '上一页', + onTap: onPreviousPage, + ), + const SizedBox(width: 3), + _PaneIconButton( + icon: Icons.chevron_right_rounded, + tooltip: '下一页', + onTap: onNextPage, + ), + ], + ), + ); + } +} + +class _PaneIconButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback? onTap; + + const _PaneIconButton({ + required this.icon, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return ShadTooltip( + builder: (_) => Text(tooltip), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 26, + height: 26, + child: Icon( + icon, + size: 16, + color: onTap == null + ? cs.mutedForeground.withValues(alpha: 0.45) + : cs.foreground, + ), + ), + ), + ); + } } class _FilePaneFrame extends StatelessWidget { @@ -1282,6 +1520,12 @@ class _FilePaneFrame extends StatelessWidget { onUploadLocalFiles; final Widget header; final Widget child; + final int currentPage; + final int pageSize; + final int totalPages; + final VoidCallback? onPreviousPage; + final VoidCallback? onNextPage; + final ValueChanged? onPageSizeChanged; const _FilePaneFrame({ required this.title, @@ -1296,6 +1540,12 @@ class _FilePaneFrame extends StatelessWidget { this.onUploadLocalFiles, required this.header, required this.child, + this.currentPage = 0, + this.pageSize = 50, + this.totalPages = 1, + this.onPreviousPage, + this.onNextPage, + this.onPageSizeChanged, }); @override @@ -1306,70 +1556,78 @@ class _FilePaneFrame extends StatelessWidget { onMoveCloudFiles: onMoveCloudFiles, onUploadLocalFiles: onUploadLocalFiles, child: Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.42), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white.withValues(alpha: 0.62)), - ), - child: Column( - children: [ - Container( - height: 42, - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - Text( - title, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w800, - color: cs.foreground, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.42), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.62)), + ), + child: Column( + children: [ + Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + color: cs.foreground, + ), ), - ), - const SizedBox(width: 8), - Text( - '$itemCount 项', - style: TextStyle(fontSize: 11, color: cs.mutedForeground), - ), - ], + const SizedBox(width: 8), + Text( + '$itemCount 项', + style: TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + ], + ), ), - ), - Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), - SizedBox( - height: 36, - child: BreadcrumbBar( - path: breadcrumbPath, - onNavigate: onBreadcrumbNavigate ?? (_) {}, + Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), + SizedBox( + height: 36, + child: BreadcrumbBar( + path: breadcrumbPath, + onNavigate: onBreadcrumbNavigate ?? (_) {}, + ), ), - ), - Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), - SizedBox(height: 34, child: header), - Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), - Expanded( - child: isLoading - ? const _ShadLoading() - : errorMessage != null - ? Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - errorMessage!, - textAlign: TextAlign.center, - style: TextStyle(color: cs.destructive), + Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), + SizedBox(height: 34, child: header), + Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), + Expanded( + child: isLoading + ? const _ShadLoading() + : errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + errorMessage!, + textAlign: TextAlign.center, + style: TextStyle(color: cs.destructive), + ), ), - ), - ) - : itemCount == 0 - ? Center( - child: Text( - emptyLabel, - style: TextStyle(color: cs.mutedForeground), - ), - ) - : child, - ), - ], - ), + ) + : itemCount == 0 + ? Center( + child: Text( + emptyLabel, + style: TextStyle(color: cs.mutedForeground), + ), + ) + : child, + ), + _PanePagination( + currentPage: currentPage, + pageSize: pageSize, + totalPages: totalPages, + onPreviousPage: onPreviousPage, + onNextPage: onNextPage, + onPageSizeChanged: onPageSizeChanged, + ), + ], + ), ), ); } @@ -1450,7 +1708,10 @@ class _PaneDropSurfaceState extends State<_PaneDropSurface> { onWillAcceptWithDetails: (_) => widget.onMoveCloudFiles != null, onAcceptWithDetails: (details) async { setState(() => _active = false); - await widget.onMoveCloudFiles?.call(details.data.files, widget.parentID); + await widget.onMoveCloudFiles?.call( + details.data.files, + widget.parentID, + ); }, onMove: (_) { if (!_active) setState(() => _active = true); @@ -1526,7 +1787,10 @@ class _DragFeedback extends StatelessWidget { label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), ), ), ], diff --git a/lib/providers/file_provider.dart b/lib/providers/file_provider.dart index 0c4fd40..e2f0882 100644 --- a/lib/providers/file_provider.dart +++ b/lib/providers/file_provider.dart @@ -256,6 +256,16 @@ class FileNotifier extends StateNotifier { } void navigateToPathIndex(int index) { + if (index < 0) { + state = state.copyWith( + folderPath: const [], + currentPage: 0, + selectedIDs: {}, + ); + loadFiles(parentID: null); + return; + } + if (index >= state.folderPath.length) return; final newPath = state.folderPath.sublist(0, index + 1); state = state.copyWith( folderPath: newPath, @@ -443,7 +453,8 @@ class FileNotifier extends StateNotifier { for (final file in files) { if (!await file.exists()) continue; state = state.copyWith( - statusMessage: '正在上传 ${completed + 1}/${files.length}:${file.uri.pathSegments.last}', + statusMessage: + '正在上传 ${completed + 1}/${files.length}:${file.uri.pathSegments.last}', ); await _api!.fileUpload(file, parentID: targetParentID); completed += 1; @@ -459,7 +470,10 @@ class FileNotifier extends StateNotifier { if (_api == null || files.isEmpty) return; state = state.copyWith(statusMessage: '正在移动 ${files.length} 个项目…'); try { - await _api!.fsMove(files.map((file) => file.id).toList(), parentID: parentID); + await _api!.fsMove( + files.map((file) => file.id).toList(), + parentID: parentID, + ); state = state.copyWith(statusMessage: '移动完成', selectedIDs: {}); await loadFiles(parentID: _currentParentID); } catch (e) { diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index a9b1acc..660041a 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -249,6 +249,17 @@ class MediaLibraryNotifier extends StateNotifier { state = state.copyWith(searchQuery: query); } + Future> searchAllItems(String query) async { + final normalized = query.trim().toLowerCase(); + if (normalized.isEmpty) return const []; + return _loadAllItems().where((item) { + return item.title.toLowerCase().contains(normalized) || + item.file.name.toLowerCase().contains(normalized) || + item.file.cloudPath.toLowerCase().contains(normalized); + }).toList() + ..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); + } + void clearError() { state = state.copyWith(clearError: true); } diff --git a/lib/widgets/sort_menu.dart b/lib/widgets/sort_menu.dart index b009635..63a2ecb 100644 --- a/lib/widgets/sort_menu.dart +++ b/lib/widgets/sort_menu.dart @@ -17,73 +17,43 @@ class SortMenu extends StatelessWidget { @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; - - return ShadButton.ghost( - onPressed: () => _showSortMenu(context), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.sort_rounded, size: 16, color: cs.mutedForeground), - const SizedBox(width: 4), - Text('排序', style: TextStyle(fontSize: 13, color: cs.mutedForeground)), - ], - ), - ); - } - - void _showSortMenu(BuildContext context) { - final cs = ShadTheme.of(context).colorScheme; - - showModalBottomSheet( - context: context, - builder: (ctx) => Container( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Column( + return SizedBox( + width: 124, + child: ShadSelect( + initialValue: currentSort, + selectedOptionBuilder: (context, value) => Row( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + Icon(Icons.sort_rounded, size: 15, color: cs.mutedForeground), + const SizedBox(width: 5), + Text( + value.title, + style: TextStyle(fontSize: 12, color: cs.foreground), + ), + ], + ), + options: [ + for (final sort in FileSort.values) + ShadOption( + value: sort, child: Row( children: [ - Text('排序方式', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: cs.foreground)), - const Spacer(), - Text('当前: ${currentSort.title}', - style: TextStyle(fontSize: 12, color: cs.mutedForeground)), + Expanded(child: Text(sort.title)), + if (currentSort == sort) + Icon( + currentDirection == SortDirection.ascending + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 14, + color: cs.primary, + ), ], ), ), - const ShadSeparator.horizontal(), - for (final sort in FileSort.values) - ListTile( - dense: true, - leading: currentSort == sort - ? Icon(Icons.check_rounded, size: 16, color: cs.primary) - : const SizedBox(width: 16), - title: Text( - sort.title, - style: TextStyle( - fontSize: 14, - fontWeight: currentSort == sort ? FontWeight.w600 : FontWeight.normal, - color: currentSort == sort ? cs.primary : cs.foreground, - ), - ), - trailing: currentSort == sort - ? Icon( - currentDirection == SortDirection.ascending - ? Icons.arrow_upward_rounded - : Icons.arrow_downward_rounded, - size: 14, - color: cs.primary, - ) - : null, - onTap: () { - Navigator.of(ctx).pop(); - onSortChanged(sort); - }, - ), - ], - ), + ], + onChanged: (value) { + if (value != null) onSortChanged(value); + }, ), ); }