import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../../data/models/dav_account_model.dart'; import '../../../data/models/server_model.dart'; import '../../../services/webdav_service.dart'; import '../../../services/storage_service.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/toast_helper.dart'; class WebdavPage extends StatefulWidget { const WebdavPage({super.key}); @override State createState() => _WebdavPageState(); } class _WebdavPageState extends State { List _accounts = []; bool _isLoading = false; String? _errorMessage; String _searchQuery = ''; final TextEditingController _searchController = TextEditingController(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _loadAccounts(); }); } @override void dispose() { _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('WebDAV'), actions: [ IconButton( icon: const Icon(LucideIcons.refreshCw), onPressed: () => _loadAccounts(), tooltip: '刷新', ), ], ), body: Column( children: [ _buildSearchBar(), Expanded(child: _buildBody()), ], ), floatingActionButton: LayoutBuilder( builder: (context, constraints) { final isDesktop = constraints.maxWidth >= 800; if (isDesktop) return const SizedBox.shrink(); return FloatingActionButton.extended( onPressed: () => _showCreateDialog(context), label: const Text('添加账户'), icon: const Icon(LucideIcons.plus), ); }, ), ); } Widget _buildSearchBar() { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: SizedBox( height: 40, child: TextField( controller: _searchController, decoration: InputDecoration( hintText: '搜索 WebDAV 账户...', prefixIcon: const Icon(LucideIcons.search, size: 20), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none, ), filled: true, fillColor: theme.colorScheme.surfaceContainerHighest .withValues(alpha: 0.5), contentPadding: const EdgeInsets.symmetric(vertical: 8), isDense: true, ), onChanged: (value) { _searchQuery = value.toLowerCase(); setState(() {}); }, ), ), ); } Widget _buildBody() { final filteredAccounts = _searchQuery.isEmpty ? _accounts : _accounts .where((a) => a.name.toLowerCase().contains(_searchQuery) || a.uri.toLowerCase().contains(_searchQuery)) .toList(); if (_isLoading && _accounts.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (_errorMessage != null) return _buildErrorState(); if (filteredAccounts.isEmpty) { return _searchQuery.isEmpty ? _buildEmptyState() : _buildNoSearchResult(); } return RefreshIndicator( onRefresh: () => _loadAccounts(), child: LayoutBuilder( builder: (context, constraints) { final isDesktop = constraints.maxWidth >= 800; return isDesktop ? _buildDesktopLayout(filteredAccounts) : _buildMobileLayout(filteredAccounts); }, ), ); } // ─── 桌面端布局 ─── Widget _buildDesktopLayout(List accounts) { final colorScheme = Theme.of(context).colorScheme; return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: Column( children: [ SizedBox( width: double.infinity, child: Card( margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, child: DataTable( headingRowColor: WidgetStateProperty.all(colorScheme.surfaceContainerHighest), columnSpacing: 24, columns: const [ DataColumn(label: Text('名称', style: TextStyle(fontWeight: FontWeight.bold))), DataColumn(label: Text('URI', style: TextStyle(fontWeight: FontWeight.bold))), DataColumn(label: Text('密码', style: TextStyle(fontWeight: FontWeight.bold))), DataColumn(label: Text('创建时间', style: TextStyle(fontWeight: FontWeight.bold))), DataColumn(label: Text('操作', style: TextStyle(fontWeight: FontWeight.bold))), ], rows: accounts.map((account) { return DataRow( cells: [ DataCell( ConstrainedBox( constraints: const BoxConstraints(maxWidth: 200), child: Row( children: [ _buildAccountIcon(colorScheme, size: 18), const SizedBox(width: 12), Expanded(child: Text(account.name, overflow: TextOverflow.ellipsis)), ], ), ), ), DataCell(Text(account.uri, style: const TextStyle(fontSize: 12))), DataCell(Text(_maskPassword(account.password), style: const TextStyle(fontFamily: 'monospace', fontSize: 12))), DataCell(Text(_formatDate(account.createdAt), style: const TextStyle(fontSize: 12))), DataCell( Row( mainAxisSize: MainAxisSize.min, children: [ _buildActionButton( icon: LucideIcons.copy, tooltip: '复制凭据', onPressed: () => _copyCredentials(context, account), ), _buildActionButton( icon: LucideIcons.pencil, tooltip: '编辑', onPressed: () => _showEditDialog(context, account), ), _buildActionButton( icon: LucideIcons.trash2, tooltip: '删除', color: colorScheme.error, onPressed: () => _showDeleteDialog(context, account), ), ], ), ), ], ); }).toList(), ), ), ), const SizedBox(height: 16), FilledButton.icon( onPressed: () => _showCreateDialog(context), icon: const Icon(LucideIcons.plus, size: 18), label: const Text('添加账户'), ), ], ), ); } // ─── 移动端布局 ─── Widget _buildMobileLayout(List accounts) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: accounts.length, itemBuilder: (context, index) { final account = accounts[index]; return InkWell( onTap: () => _showMobileActionMenu(account), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), child: Row( children: [ _buildAccountIcon(colorScheme), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( account.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), ), const SizedBox(height: 2), Text( '${account.uri} · ${_maskPassword(account.password)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.hintColor, ), ), ], ), ), IconButton( icon: const Icon(LucideIcons.moreVertical, size: 18), onPressed: () => _showMobileActionMenu(account), style: IconButton.styleFrom( padding: const EdgeInsets.all(8), minimumSize: const Size(36, 36), ), ), ], ), ), ); }, ); } // ─── 账户图标 ─── Widget _buildAccountIcon(ColorScheme colorScheme, {double size = 18}) { return Container( width: 36, height: 36, decoration: BoxDecoration( color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8), ), child: Icon(LucideIcons.cloud, color: colorScheme.onPrimaryContainer, size: size), ); } // ─── 桌面端操作按钮 ─── Widget _buildActionButton({ required IconData icon, required String tooltip, Color? color, required VoidCallback onPressed, }) { return IconButton( icon: Icon(icon, size: 18, color: color), onPressed: onPressed, tooltip: tooltip, style: IconButton.styleFrom( padding: const EdgeInsets.all(4), minimumSize: const Size(32, 32), ), ); } String _maskPassword(String password) { if (password.length <= 4) return '••••'; return '••••${password.substring(password.length - 4)}'; } String _formatDate(DateTime date) { final now = DateTime.now(); final diff = now.difference(date); if (diff.inDays == 0) return '今天'; if (diff.inDays == 1) return '昨天'; if (diff.inDays < 7) return '${diff.inDays} 天前'; return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } // ─── 移动端菜单 ─── void _showMobileActionMenu(DavAccountModel account) { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(LucideIcons.copy), title: const Text('复制凭据'), onTap: () { Navigator.pop(context); _copyCredentials(this.context, account); }, ), ListTile( leading: const Icon(LucideIcons.pencil), title: const Text('编辑账户'), onTap: () { Navigator.pop(context); _showEditDialog(this.context, account); }, ), ListTile( leading: Icon(LucideIcons.trash2, color: colorScheme.error), title: Text('删除账户', style: TextStyle(color: colorScheme.error)), onTap: () { Navigator.pop(context); _showDeleteDialog(this.context, account); }, ), ], ), ), ); } // ─── 空状态 / 错误状态 ─── Widget _buildEmptyState() { final theme = Theme.of(context); return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(LucideIcons.cloud, size: 48, color: theme.colorScheme.outline), const SizedBox(height: 16), Text('暂无 WebDAV 账户', style: TextStyle(color: theme.hintColor)), const SizedBox(height: 8), Text('点击下方按钮添加账户', style: TextStyle(fontSize: 12, color: theme.hintColor)), ], ), ); } Widget _buildNoSearchResult() { final theme = Theme.of(context); return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(LucideIcons.searchX, size: 48, color: theme.colorScheme.outline), const SizedBox(height: 16), Text('没有找到 "$_searchQuery"', style: TextStyle(color: theme.hintColor)), ], ), ); } Widget _buildErrorState() { final theme = Theme.of(context); return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(LucideIcons.alertCircle, size: 48, color: theme.colorScheme.error), const SizedBox(height: 16), Text('加载失败', style: TextStyle(color: theme.hintColor)), const SizedBox(height: 8), Text(_errorMessage ?? '未知错误', style: TextStyle(fontSize: 12, color: theme.hintColor)), const SizedBox(height: 24), FilledButton.icon( icon: const Icon(LucideIcons.refreshCw, size: 18), label: const Text('重试'), onPressed: _loadAccounts, ), ], ), ); } // ─── 数据操作 ─── Future _loadAccounts() async { if (!mounted) return; setState(() { _isLoading = true; _errorMessage = null; }); try { final response = await WebdavService().listAccounts(pageSize: 50); final accountsData = response as Map?; final accountsList = accountsData?['accounts'] as List? ?? []; final accounts = accountsList .map((a) => DavAccountModel.fromJson(a as Map)) .toList(); if (!mounted) return; setState(() { _accounts = accounts; _isLoading = false; }); ToastHelper.success('刷新成功'); } catch (e) { if (!mounted) return; setState(() { _isLoading = false; _errorMessage = e.toString(); }); ToastHelper.failure('刷新失败: $e'); } } void _copyCredentials(BuildContext context, DavAccountModel account) async { final authProvider = Provider.of(context, listen: false); final username = authProvider.user?.email ?? account.id; final storageService = StorageService.instance; final servers = await storageService.servers; final lastLabel = await storageService.lastSelectedServerLabel; String davUrl = account.uri; if (lastLabel != null) { final currentServer = servers.firstWhere( (s) => s.label == lastLabel, orElse: () => ServerModel(label: '', baseUrl: ''), ); if (currentServer.baseUrl.isNotEmpty) { final cleanBaseUrl = currentServer.baseUrl.replaceAll(RegExp(r'/api/v4/?$'), ''); davUrl = '$cleanBaseUrl/dav'; } } final credentials = '地址: $davUrl\n用户: $username\n密码: ${account.password}'; Clipboard.setData(ClipboardData(text: credentials)); if (mounted) ToastHelper.success('凭据已复制到剪贴板'); } Future _showCreateDialog(BuildContext context) async { final nameController = TextEditingController(); final uriController = TextEditingController(); final proxyController = TextEditingController(text: 'false'); final readonlyController = TextEditingController(text: 'false'); final created = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('添加 WebDAV 账户'), content: SizedBox( width: 400, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameController, decoration: const InputDecoration( labelText: '名称', hintText: '请输入备注名称', prefixIcon: Icon(LucideIcons.tag), ), ), const SizedBox(height: 16), TextField( controller: uriController, decoration: const InputDecoration( labelText: 'URI', hintText: '/ or /folder', prefixIcon: Icon(LucideIcons.link), ), ), const SizedBox(height: 16), Row( children: [ Expanded( child: TextField( controller: proxyController, decoration: const InputDecoration( labelText: '反向代理', hintText: 'true/false', prefixIcon: Icon(LucideIcons.arrowLeftRight), ), keyboardType: TextInputType.text, ), ), const SizedBox(width: 16), Expanded( child: TextField( controller: readonlyController, decoration: const InputDecoration( labelText: '只读', hintText: 'true/false', prefixIcon: Icon(LucideIcons.eyeOff), ), keyboardType: TextInputType.text, ), ), ], ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('创建'), ), ], ), ); if (!mounted) return; if (created == true) { final name = nameController.text.trim(); String uri = uriController.text.trim(); final proxy = proxyController.text.trim().toLowerCase() == 'true'; final readonly = readonlyController.text.trim().toLowerCase() == 'true'; if (name.isEmpty || uri.isEmpty) { ToastHelper.error('请填写名称和URI'); return; } setState(() => _isLoading = true); try { final account = await WebdavService().createAccount( uri: 'cloudreve://my$uri', name: name, proxy: proxy, readonly: readonly, ); if (!mounted) return; setState(() { _accounts.add(account); _isLoading = false; }); ToastHelper.success('创建成功'); } catch (e) { if (!mounted) return; setState(() => _isLoading = false); ToastHelper.failure('创建失败: $e'); } } } Future _showEditDialog(BuildContext context, DavAccountModel account) async { final nameController = TextEditingController(text: account.name); final uriController = TextEditingController(text: account.uri); final updated = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('编辑 WebDAV 账户'), content: SizedBox( width: 400, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameController, decoration: const InputDecoration( labelText: '名称', hintText: '请输入备注名称', prefixIcon: Icon(LucideIcons.tag), ), ), const SizedBox(height: 16), TextField( controller: uriController, decoration: const InputDecoration( labelText: 'URI', hintText: 'cloudreve://my', prefixIcon: Icon(LucideIcons.link), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('保存'), ), ], ), ); if (!mounted) return; if (updated == true) { final name = nameController.text.trim(); final uri = uriController.text.trim(); if (name.isEmpty || uri.isEmpty) { ToastHelper.error('请填写名称和URI'); return; } setState(() => _isLoading = true); try { await WebdavService().updateAccount(id: account.id, name: name, uri: uri); if (!mounted) return; setState(() { final index = _accounts.indexWhere((a) => a.id == account.id); if (index != -1) { _accounts[index] = account.copyWith(name: name, uri: uri); } _isLoading = false; }); ToastHelper.success('更新成功'); } catch (e) { if (!mounted) return; setState(() => _isLoading = false); ToastHelper.failure('更新失败: $e'); } } } Future _showDeleteDialog(BuildContext context, DavAccountModel account) async { final colorScheme = Theme.of(context).colorScheme; final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('删除账户'), content: Text('确定要删除 WebDAV 账户 "${account.name}" 吗?'), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), style: FilledButton.styleFrom(backgroundColor: colorScheme.error), child: const Text('删除'), ), ], ), ); if (!mounted) return; if (confirmed == true) { setState(() => _isLoading = true); try { await WebdavService().deleteAccount(account.id); if (!mounted) return; setState(() { _accounts.removeWhere((a) => a.id == account.id); _isLoading = false; }); ToastHelper.success('删除成功'); } catch (e) { if (!mounted) return; setState(() => _isLoading = false); ToastHelper.failure('删除失败: $e'); } } } } extension DavAccountModelExtension on DavAccountModel { DavAccountModel copyWith({ String? name, String? uri, DateTime? createdAt, String? password, String? options, }) { return DavAccountModel( id: id, createdAt: createdAt ?? this.createdAt, name: name ?? this.name, uri: uri ?? this.uri, password: password ?? this.password, options: options ?? this.options, ); } }