feat: improve workspace navigation and shadcn UI

This commit is contained in:
ngfchl
2026-07-18 10:33:28 +08:00
parent 91e9d48093
commit b975873f3b
6 changed files with 553 additions and 287 deletions
+3 -1
View File
@@ -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(),
+93 -88
View File
@@ -545,61 +545,54 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
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<MediaLibraryKind>(
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<MediaLibraryKind>(
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<String>(
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),
),
),
],
),
),
),
);
}
+400 -136
View File
@@ -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<WorkspacePage> {
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<WorkspacePage> {
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<WorkspacePage> {
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<WorkspacePage> {
),
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<WorkspacePage> {
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<WorkspaceSection> onSection;
final VoidCallback onSettings;
final VoidCallback onSignOut;
final ValueChanged<WorkspaceTool> 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<WorkspaceTool> 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<void> _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<String>().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<CloudFile>? 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 = <CloudFile>[];
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<String, dynamic> json, int itemCount) {
int? find(Map<String, dynamic> 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<String, dynamic>.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<int>? 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<int>(
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<int>? 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,
),
),
),
],
+16 -2
View File
@@ -256,6 +256,16 @@ class FileNotifier extends StateNotifier<FileState> {
}
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<FileState> {
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<FileState> {
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) {
+11
View File
@@ -249,6 +249,17 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
state = state.copyWith(searchQuery: query);
}
Future<List<MediaLibraryItem>> 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);
}
+30 -60
View File
@@ -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<FileSort>(
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);
},
),
);
}