diff --git a/lib/main.dart b/lib/main.dart index fb191cf..7aa33dd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:openlist_mobile/pages/download_manager_page.dart'; import 'package:openlist_mobile/utils/download_manager.dart'; import 'package:openlist_mobile/utils/notification_manager.dart'; import 'package:openlist_mobile/utils/service_manager.dart'; +import 'package:openlist_mobile/services/auth_manager.dart'; import 'package:fade_indexed_stack/fade_indexed_stack.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -26,6 +27,9 @@ void main() async { // 初始化服务管理器 await ServiceManager.instance.initialize(); + // 初始化认证管理器 + Get.put(AuthManager()); + // Android if (!kIsWeb && kDebugMode && diff --git a/lib/models/storage.dart b/lib/models/storage.dart new file mode 100644 index 0000000..1908590 --- /dev/null +++ b/lib/models/storage.dart @@ -0,0 +1,184 @@ +class Storage { + final int? id; + final String mountPath; + final int order; + final String driver; + final int cacheExpiration; + final String status; + final String addition; + final String remark; + final DateTime? modified; + final bool disabled; + final bool disableIndex; + final bool enableSign; + final String orderBy; + final String orderDirection; + final String extractFolder; + final bool webProxy; + final String webdavPolicy; + final bool proxyRange; + final String downProxyURL; + final bool disableProxySign; + + Storage({ + this.id, + required this.mountPath, + this.order = 0, + required this.driver, + this.cacheExpiration = 30, + this.status = '', + this.addition = '', + this.remark = '', + this.modified, + this.disabled = false, + this.disableIndex = false, + this.enableSign = false, + this.orderBy = 'name', + this.orderDirection = 'asc', + this.extractFolder = '', + this.webProxy = false, + this.webdavPolicy = '', + this.proxyRange = false, + this.downProxyURL = '', + this.disableProxySign = false, + }); + + factory Storage.fromJson(Map json) { + return Storage( + id: json['id'], + mountPath: json['mount_path'] ?? '', + order: json['order'] ?? 0, + driver: json['driver'] ?? '', + cacheExpiration: json['cache_expiration'] ?? 30, + status: json['status'] ?? '', + addition: json['addition'] ?? '', + remark: json['remark'] ?? '', + modified: json['modified'] != null ? DateTime.parse(json['modified']) : null, + disabled: json['disabled'] ?? false, + disableIndex: json['disable_index'] ?? false, + enableSign: json['enable_sign'] ?? false, + orderBy: json['order_by'] ?? 'name', + orderDirection: json['order_direction'] ?? 'asc', + extractFolder: json['extract_folder'] ?? '', + webProxy: json['web_proxy'] ?? false, + webdavPolicy: json['webdav_policy'] ?? '', + proxyRange: json['proxy_range'] ?? false, + downProxyURL: json['down_proxy_url'] ?? '', + disableProxySign: json['disable_proxy_sign'] ?? false, + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'mount_path': mountPath, + 'order': order, + 'driver': driver, + 'cache_expiration': cacheExpiration, + 'status': status, + 'addition': addition, + 'remark': remark, + if (modified != null) 'modified': modified!.toIso8601String(), + 'disabled': disabled, + 'disable_index': disableIndex, + 'enable_sign': enableSign, + 'order_by': orderBy, + 'order_direction': orderDirection, + 'extract_folder': extractFolder, + 'web_proxy': webProxy, + 'webdav_policy': webdavPolicy, + 'proxy_range': proxyRange, + 'down_proxy_url': downProxyURL, + 'disable_proxy_sign': disableProxySign, + }; + } + + Storage copyWith({ + int? id, + String? mountPath, + int? order, + String? driver, + int? cacheExpiration, + String? status, + String? addition, + String? remark, + DateTime? modified, + bool? disabled, + bool? disableIndex, + bool? enableSign, + String? orderBy, + String? orderDirection, + String? extractFolder, + bool? webProxy, + String? webdavPolicy, + bool? proxyRange, + String? downProxyURL, + bool? disableProxySign, + }) { + return Storage( + id: id ?? this.id, + mountPath: mountPath ?? this.mountPath, + order: order ?? this.order, + driver: driver ?? this.driver, + cacheExpiration: cacheExpiration ?? this.cacheExpiration, + status: status ?? this.status, + addition: addition ?? this.addition, + remark: remark ?? this.remark, + modified: modified ?? this.modified, + disabled: disabled ?? this.disabled, + disableIndex: disableIndex ?? this.disableIndex, + enableSign: enableSign ?? this.enableSign, + orderBy: orderBy ?? this.orderBy, + orderDirection: orderDirection ?? this.orderDirection, + extractFolder: extractFolder ?? this.extractFolder, + webProxy: webProxy ?? this.webProxy, + webdavPolicy: webdavPolicy ?? this.webdavPolicy, + proxyRange: proxyRange ?? this.proxyRange, + downProxyURL: downProxyURL ?? this.downProxyURL, + disableProxySign: disableProxySign ?? this.disableProxySign, + ); + } +} + +class PageResponse { + final List content; + final int total; + + PageResponse({ + required this.content, + required this.total, + }); + + factory PageResponse.fromJson(Map json, T Function(Map) fromJsonT) { + return PageResponse( + content: (json['content'] as List) + .map((item) => fromJsonT(item as Map)) + .toList(), + total: json['total'] ?? 0, + ); + } +} + +class ApiResponse { + final int code; + final String message; + final T? data; + + ApiResponse({ + required this.code, + required this.message, + this.data, + }); + + bool get isSuccess => code == 200; + + factory ApiResponse.fromJson(Map json, T Function(Map)? fromJsonT) { + return ApiResponse( + code: json['code'] ?? 0, + message: json['message'] ?? '', + data: json['data'] != null && fromJsonT != null + ? fromJsonT(json['data'] as Map) + : json['data'] as T?, + ); + } +} diff --git a/lib/pages/auth/login_page.dart b/lib/pages/auth/login_page.dart new file mode 100644 index 0000000..5d04644 --- /dev/null +++ b/lib/pages/auth/login_page.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../services/auth_manager.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({Key? key}) : super(key: key); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _authManager = AuthManager.instance; + + bool _isLoading = false; + bool _rememberMe = true; + bool _obscurePassword = true; + + @override + void initState() { + super.initState(); + _loadStoredCredentials(); + } + + void _loadStoredCredentials() async { + final credentials = await _authManager.getStoredCredentials(); + if (credentials != null) { + _usernameController.text = credentials['username'] ?? ''; + _passwordController.text = credentials['password'] ?? ''; + } + } + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo 区域 + const Icon( + Icons.storage, + size: 80, + color: Colors.blue, + ), + const SizedBox(height: 24), + Text( + 'OpenList 管理员登录', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + '请登录以管理存储配置', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 48), + + // 用户名输入框 + TextFormField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: '用户名', + hintText: '请输入管理员用户名', + prefixIcon: Icon(Icons.person), + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入用户名'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // 密码输入框 + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: '密码', + hintText: '请输入管理员密码', + prefixIcon: const Icon(Icons.lock), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + border: const OutlineInputBorder(), + ), + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _login(), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入密码'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // 记住我选项 + Row( + children: [ + Checkbox( + value: _rememberMe, + onChanged: (value) { + setState(() { + _rememberMe = value ?? false; + }); + }, + ), + const Text('记住登录信息'), + ], + ), + const SizedBox(height: 24), + + // 登录按钮 + SizedBox( + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _login, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text( + '登录', + style: TextStyle(fontSize: 16), + ), + ), + ), + const SizedBox(height: 24), + + // 提示信息 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: Colors.blue[700], size: 20), + const SizedBox(width: 8), + Text( + '提示', + style: TextStyle( + color: Colors.blue[700], + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '• 请使用 OpenList 后端的管理员账户\n' + '• 确保 OpenList 服务正在运行\n' + '• 默认用户名通常为 admin', + style: TextStyle( + color: Colors.blue[600], + fontSize: 14, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + void _login() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final result = await _authManager.login( + _usernameController.text.trim(), + _passwordController.text, + _rememberMe, + ); + + if (result.success) { + // 登录成功,返回上一页 + Get.back(); + Get.snackbar( + '登录成功', + '欢迎回来,${_usernameController.text}', + backgroundColor: Colors.green, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } else { + // 登录失败,显示错误信息 + Get.snackbar( + '登录失败', + result.message ?? '未知错误', + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } + } catch (e) { + Get.snackbar( + '登录失败', + '发生未知错误:$e', + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } finally { + setState(() { + _isLoading = false; + }); + } + } +} diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 133d62e..0c57494 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -1,6 +1,7 @@ import 'package:openlist_mobile/contant/native_bridge.dart'; import 'package:openlist_mobile/generated_api.dart'; import 'package:openlist_mobile/pages/settings/preference_widgets.dart'; +import 'package:openlist_mobile/pages/storage/storage_management_page.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -83,6 +84,15 @@ class _SettingsScreenState extends State { DividerPreference(title: S.of(context).general), + BasicPreference( + title: '存储管理', + subtitle: '管理和配置存储驱动', + leading: const Icon(Icons.storage), + onTap: () { + Get.to(() => const StorageManagementPage()); + }, + ), + SwitchPreference( title: S.of(context).autoCheckForUpdates, subtitle: S.of(context).autoCheckForUpdatesDesc, diff --git a/lib/pages/storage/storage_detail_page.dart b/lib/pages/storage/storage_detail_page.dart new file mode 100644 index 0000000..a756d81 --- /dev/null +++ b/lib/pages/storage/storage_detail_page.dart @@ -0,0 +1,431 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../models/storage.dart'; +import '../../services/storage_api_service.dart'; + +class StorageDetailPage extends StatefulWidget { + final Storage? storage; + + const StorageDetailPage({Key? key, this.storage}) : super(key: key); + + @override + State createState() => _StorageDetailPageState(); +} + +class _StorageDetailPageState extends State { + final _formKey = GlobalKey(); + final _mountPathController = TextEditingController(); + final _remarkController = TextEditingController(); + final _orderController = TextEditingController(); + final _cacheExpirationController = TextEditingController(); + final _additionController = TextEditingController(); + + String _selectedDriver = 'local'; + bool _disabled = false; + bool _disableIndex = false; + bool _enableSign = false; + bool _webProxy = false; + bool _proxyRange = false; + + final List _drivers = [ + 'local', + 'aliyundrive', + 'aliyundrive_open', + 'onedrive', + 'googledrive', + 'dropbox', + 'webdav', + 'ftp', + 'sftp', + '123', + 'baidu_netdisk', + ]; + + bool get _isEditing => widget.storage != null; + + @override + void initState() { + super.initState(); + if (_isEditing) { + _loadStorageData(); + } else { + _setDefaultValues(); + } + } + + void _loadStorageData() { + final storage = widget.storage!; + _mountPathController.text = storage.mountPath; + _remarkController.text = storage.remark; + _orderController.text = storage.order.toString(); + _cacheExpirationController.text = storage.cacheExpiration.toString(); + _additionController.text = storage.addition; + _selectedDriver = storage.driver; + _disabled = storage.disabled; + _disableIndex = storage.disableIndex; + _enableSign = storage.enableSign; + _webProxy = storage.webProxy; + _proxyRange = storage.proxyRange; + } + + void _setDefaultValues() { + _orderController.text = '0'; + _cacheExpirationController.text = '30'; + } + + @override + void dispose() { + _mountPathController.dispose(); + _remarkController.dispose(); + _orderController.dispose(); + _cacheExpirationController.dispose(); + _additionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_isEditing ? '编辑存储' : '添加存储'), + actions: [ + TextButton( + onPressed: _saveStorage, + child: Text( + '保存', + style: TextStyle(color: Theme.of(context).primaryColor), + ), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildBasicSection(), + const SizedBox(height: 24), + _buildAdvancedSection(), + const SizedBox(height: 24), + _buildDriverSection(), + ], + ), + ), + ); + } + + Widget _buildBasicSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '基本设置', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + TextFormField( + controller: _mountPathController, + decoration: const InputDecoration( + labelText: '挂载路径', + hintText: '例如: /', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入挂载路径'; + } + if (!value.startsWith('/')) { + return '挂载路径必须以 / 开头'; + } + return null; + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedDriver, + decoration: const InputDecoration( + labelText: '驱动类型', + border: OutlineInputBorder(), + ), + items: _drivers.map((driver) { + return DropdownMenuItem( + value: driver, + child: Text(_getDriverDisplayName(driver)), + ); + }).toList(), + onChanged: (value) { + setState(() { + _selectedDriver = value!; + }); + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _remarkController, + decoration: const InputDecoration( + labelText: '备注', + hintText: '可选的存储描述', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _orderController, + decoration: const InputDecoration( + labelText: '排序', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入排序值'; + } + if (int.tryParse(value) == null) { + return '请输入有效的数字'; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _cacheExpirationController, + decoration: const InputDecoration( + labelText: '缓存过期时间(分钟)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入缓存过期时间'; + } + if (int.tryParse(value) == null) { + return '请输入有效的数字'; + } + return null; + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildAdvancedSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '高级设置', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('禁用存储'), + subtitle: const Text('禁用后将无法访问此存储'), + value: _disabled, + onChanged: (value) { + setState(() { + _disabled = value; + }); + }, + ), + SwitchListTile( + title: const Text('禁用索引'), + subtitle: const Text('禁用后将不会建立索引'), + value: _disableIndex, + onChanged: (value) { + setState(() { + _disableIndex = value; + }); + }, + ), + SwitchListTile( + title: const Text('启用签名'), + subtitle: const Text('启用后访问文件需要签名验证'), + value: _enableSign, + onChanged: (value) { + setState(() { + _enableSign = value; + }); + }, + ), + SwitchListTile( + title: const Text('网页代理'), + subtitle: const Text('通过网页代理访问文件'), + value: _webProxy, + onChanged: (value) { + setState(() { + _webProxy = value; + }); + }, + ), + SwitchListTile( + title: const Text('代理范围请求'), + subtitle: const Text('支持断点续传'), + value: _proxyRange, + onChanged: (value) { + setState(() { + _proxyRange = value; + }); + }, + ), + ], + ), + ), + ); + } + + Widget _buildDriverSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '驱动配置', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + TextFormField( + controller: _additionController, + decoration: const InputDecoration( + labelText: '附加配置', + hintText: '请输入JSON格式的驱动配置', + border: OutlineInputBorder(), + ), + maxLines: 8, + validator: (value) { + // 可以在这里添加JSON格式验证 + return null; + }, + ), + const SizedBox(height: 8), + Text( + '请根据所选驱动类型输入相应的配置参数,格式为JSON', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ), + ), + ); + } + + String _getDriverDisplayName(String driver) { + switch (driver) { + case 'local': + return '本地存储'; + case 'aliyundrive': + return '阿里云盘'; + case 'aliyundrive_open': + return '阿里云盘开放平台'; + case 'onedrive': + return 'OneDrive'; + case 'googledrive': + return 'Google Drive'; + case 'dropbox': + return 'Dropbox'; + case 'webdav': + return 'WebDAV'; + case 'ftp': + return 'FTP'; + case 'sftp': + return 'SFTP'; + case '123': + return '123云盘'; + case 'baidu_netdisk': + return '百度网盘'; + default: + return driver; + } + } + + void _saveStorage() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final storage = Storage( + id: widget.storage?.id, + mountPath: _mountPathController.text.trim(), + driver: _selectedDriver, + remark: _remarkController.text.trim(), + order: int.parse(_orderController.text), + cacheExpiration: int.parse(_cacheExpirationController.text), + addition: _additionController.text.trim(), + disabled: _disabled, + disableIndex: _disableIndex, + enableSign: _enableSign, + webProxy: _webProxy, + proxyRange: _proxyRange, + ); + + try { + // 显示加载对话框 + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('保存中...'), + ], + ), + ), + ); + + final response = _isEditing + ? await StorageApiService.updateStorage(storage) + : await StorageApiService.createStorage(storage); + + Navigator.of(context).pop(); // 关闭加载对话框 + + if (response.isSuccess) { + Get.snackbar( + '成功', + _isEditing ? '存储更新成功' : '存储创建成功', + backgroundColor: Colors.green, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + Navigator.of(context).pop(); // 返回上一页 + } else { + Get.snackbar( + '错误', + response.message, + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } + } catch (e) { + Navigator.of(context).pop(); // 关闭加载对话框 + Get.snackbar( + '错误', + e.toString(), + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } + } +} diff --git a/lib/pages/storage/storage_management_page.dart b/lib/pages/storage/storage_management_page.dart new file mode 100644 index 0000000..dee6285 --- /dev/null +++ b/lib/pages/storage/storage_management_page.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../models/storage.dart'; +import '../../services/storage_api_service.dart'; +import '../../services/auth_manager.dart'; +import '../../pages/auth/login_page.dart'; +import 'storage_detail_page.dart'; + +class StorageManagementPage extends StatefulWidget { + const StorageManagementPage({Key? key}) : super(key: key); + + @override + State createState() => _StorageManagementPageState(); +} + +class _StorageManagementPageState extends State { + final StorageController _controller = Get.put(StorageController()); + + @override + void initState() { + super.initState(); + _controller.loadStorages(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('存储管理'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => _controller.loadStorages(), + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _showAddStorageDialog(), + ), + PopupMenuButton( + onSelected: (value) { + if (value == 'logout') { + _logout(); + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout), + SizedBox(width: 8), + Text('退出登录'), + ], + ), + ), + ], + ), + ], + ), + body: Obx(() { + if (_controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (_controller.storages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.storage_outlined, + size: 80, + color: Colors.grey, + ), + const SizedBox(height: 16), + Text( + '未配置存储', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey, + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => _showAddStorageDialog(), + icon: const Icon(Icons.add), + label: const Text('添加存储'), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () => _controller.loadStorages(), + child: ListView.builder( + itemCount: _controller.storages.length, + itemBuilder: (context, index) { + final storage = _controller.storages[index]; + return _buildStorageCard(storage); + }, + ), + ); + }), + ); + } + + Widget _buildStorageCard(Storage storage) { + final isEnabled = !storage.disabled; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: isEnabled ? Colors.green : Colors.grey, + child: Icon( + _getDriverIcon(storage.driver), + color: Colors.white, + ), + ), + title: Text( + storage.mountPath, + style: TextStyle( + fontWeight: FontWeight.bold, + color: isEnabled ? null : Colors.grey, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('驱动: ${storage.driver}'), + if (storage.remark.isNotEmpty) + Text('备注: ${storage.remark}'), + Text( + '状态: ${isEnabled ? '已启用' : '已禁用'}', + style: TextStyle( + color: isEnabled ? Colors.green : Colors.red, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + trailing: PopupMenuButton( + onSelected: (value) => _handleMenuAction(value, storage), + itemBuilder: (context) => [ + PopupMenuItem( + value: 'edit', + child: Row( + children: [ + const Icon(Icons.edit), + const SizedBox(width: 8), + const Text('编辑'), + ], + ), + ), + PopupMenuItem( + value: isEnabled ? 'disable' : 'enable', + child: Row( + children: [ + Icon(isEnabled ? Icons.pause : Icons.play_arrow), + const SizedBox(width: 8), + Text(isEnabled ? '禁用' : '启用'), + ], + ), + ), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete, color: Colors.red), + const SizedBox(width: 8), + const Text( + '删除', + style: TextStyle(color: Colors.red), + ), + ], + ), + ), + ], + ), + onTap: () => _showStorageDetail(storage), + ), + ); + } + + IconData _getDriverIcon(String driver) { + switch (driver.toLowerCase()) { + case 'local': + return Icons.folder; + case 'aliyundrive': + case 'aliyundrive_open': + return Icons.cloud; + case 'onedrive': + return Icons.cloud_outlined; + case 'googledrive': + return Icons.cloud_queue; + case 'dropbox': + return Icons.cloud_download; + case 'ftp': + case 'sftp': + return Icons.folder_shared; + case 'webdav': + return Icons.web; + default: + return Icons.storage; + } + } + + void _handleMenuAction(String action, Storage storage) { + switch (action) { + case 'edit': + _showEditStorageDialog(storage); + break; + case 'enable': + _controller.enableStorage(storage.id!); + break; + case 'disable': + _controller.disableStorage(storage.id!); + break; + case 'delete': + _showDeleteConfirmDialog(storage); + break; + } + } + + void _showStorageDetail(Storage storage) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StorageDetailPage(storage: storage), + ), + ); + } + + void _showAddStorageDialog() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const StorageDetailPage(), + ), + ).then((_) => _controller.loadStorages()); + } + + void _showEditStorageDialog(Storage storage) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StorageDetailPage(storage: storage), + ), + ).then((_) => _controller.loadStorages()); + } + + void _showDeleteConfirmDialog(Storage storage) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认删除'), + content: Text( + '确定要删除存储 "${storage.mountPath}" 吗?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _controller.deleteStorage(storage.id!); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('删除'), + ), + ], + ), + ); + } + + void _logout() async { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认退出'), + content: const Text('确定要退出登录吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + await AuthManager.instance.logout(); + Get.snackbar( + '已退出', + '已安全退出登录', + backgroundColor: Colors.green, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('退出'), + ), + ], + ), + ); + } +} + +class StorageController extends GetxController { + final RxList storages = [].obs; + final RxBool isLoading = false.obs; + + Future loadStorages() async { + isLoading.value = true; + try { + final response = await StorageApiService.getStorages(); + if (response.isSuccess && response.data != null) { + storages.value = response.data!.content; + } else { + _showError(response.message); + } + } catch (e) { + _showError(e.toString()); + } finally { + isLoading.value = false; + } + } + + Future enableStorage(int id) async { + try { + final response = await StorageApiService.enableStorage(id); + if (response.isSuccess) { + _showSuccess('存储已启用'); + loadStorages(); + } else { + _showError(response.message); + } + } catch (e) { + _showError(e.toString()); + } + } + + Future disableStorage(int id) async { + try { + final response = await StorageApiService.disableStorage(id); + if (response.isSuccess) { + _showSuccess('存储已禁用'); + loadStorages(); + } else { + _showError(response.message); + } + } catch (e) { + _showError(e.toString()); + } + } + + Future deleteStorage(int id) async { + try { + final response = await StorageApiService.deleteStorage(id); + if (response.isSuccess) { + _showSuccess('存储已删除'); + loadStorages(); + } else { + _showError(response.message); + } + } catch (e) { + _showError(e.toString()); + } + } + + void _showSuccess(String message) { + Get.snackbar( + '成功', + message, + backgroundColor: Colors.green, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } + + void _showError(String message) { + Get.snackbar( + '错误', + message, + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } +} diff --git a/lib/services/auth_manager.dart b/lib/services/auth_manager.dart new file mode 100644 index 0000000..f56f28b --- /dev/null +++ b/lib/services/auth_manager.dart @@ -0,0 +1,180 @@ +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:dio/dio.dart'; +import '../generated_api.dart'; +import 'dart:developer'; + +class AuthManager extends GetxController { + static AuthManager get instance => Get.find(); + + final Dio _dio = Dio(); + final RxBool isLoggedIn = false.obs; + final RxString username = ''.obs; + + String? _token; + String? _baseUrl; + + static const String _keyToken = 'auth_token'; + static const String _keyUsername = 'auth_username'; + static const String _keyPassword = 'auth_password'; + static const String _keyRememberMe = 'auth_remember_me'; + + @override + void onInit() { + super.onInit(); + _loadSavedCredentials(); + } + + Future _loadSavedCredentials() async { + final prefs = await SharedPreferences.getInstance(); + final savedToken = prefs.getString(_keyToken); + final savedUsername = prefs.getString(_keyUsername); + final rememberMe = prefs.getBool(_keyRememberMe) ?? false; + + if (savedToken != null && savedUsername != null && rememberMe) { + _token = savedToken; + username.value = savedUsername; + isLoggedIn.value = true; + + // 验证 token 是否仍然有效 + final isValid = await _validateToken(); + if (!isValid) { + await logout(); + } + } + } + + Future _validateToken() async { + if (_token == null) return false; + + try { + await _initializeBaseUrl(); + final response = await _dio.get( + '$_baseUrl/api/me', + options: Options( + headers: {'Authorization': _token}, + ), + ); + + return response.statusCode == 200 && response.data['code'] == 200; + } catch (e) { + log('Token validation failed: $e'); + return false; + } + } + + Future _initializeBaseUrl() async { + if (_baseUrl == null) { + // 这里需要导入 Android 类 + try { + final port = await Android().getOpenListHttpPort(); + _baseUrl = 'http://localhost:$port'; + } catch (e) { + _baseUrl = 'http://localhost:5244'; // 默认端口 + } + } + } + + Future login(String inputUsername, String inputPassword, bool rememberMe) async { + try { + await _initializeBaseUrl(); + + final response = await _dio.post( + '$_baseUrl/api/auth/login', + data: { + 'username': inputUsername, + 'password': inputPassword, + }, + ); + + if (response.statusCode == 200 && response.data['code'] == 200) { + _token = response.data['data']['token']; + username.value = inputUsername; + isLoggedIn.value = true; + + // 保存凭据 + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyToken, _token!); + await prefs.setString(_keyUsername, inputUsername); + await prefs.setBool(_keyRememberMe, rememberMe); + + if (rememberMe) { + await prefs.setString(_keyPassword, inputPassword); + } else { + await prefs.remove(_keyPassword); + } + + return LoginResult.success(); + } else { + return LoginResult.failure(response.data['message'] ?? '登录失败'); + } + } catch (e) { + log('Login failed: $e'); + if (e is DioException) { + if (e.response?.statusCode == 401) { + return LoginResult.failure('用户名或密码错误'); + } else if (e.response?.statusCode == 403) { + return LoginResult.failure('账户被禁用'); + } else { + return LoginResult.failure('网络连接失败,请检查服务是否启动'); + } + } + return LoginResult.failure('登录失败:${e.toString()}'); + } + } + + Future logout() async { + _token = null; + username.value = ''; + isLoggedIn.value = false; + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyToken); + await prefs.remove(_keyUsername); + await prefs.remove(_keyPassword); + await prefs.setBool(_keyRememberMe, false); + } + + String? get token => _token; + String? get baseUrl => _baseUrl; + + Future> getAuthHeaders() async { + await _initializeBaseUrl(); + return { + 'Content-Type': 'application/json', + if (_token != null) 'Authorization': _token!, + }; + } + + Future getAuthOptions() async { + final headers = await getAuthHeaders(); + return Options(headers: headers); + } + + Future hasStoredCredentials() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keyUsername) != null && + prefs.getString(_keyPassword) != null && + (prefs.getBool(_keyRememberMe) ?? false); + } + + Future?> getStoredCredentials() async { + if (!await hasStoredCredentials()) return null; + + final prefs = await SharedPreferences.getInstance(); + return { + 'username': prefs.getString(_keyUsername) ?? '', + 'password': prefs.getString(_keyPassword) ?? '', + }; + } +} + +class LoginResult { + final bool success; + final String? message; + + LoginResult._(this.success, this.message); + + factory LoginResult.success() => LoginResult._(true, null); + factory LoginResult.failure(String message) => LoginResult._(false, message); +} diff --git a/lib/services/storage_api_service.dart b/lib/services/storage_api_service.dart new file mode 100644 index 0000000..57a0ff6 --- /dev/null +++ b/lib/services/storage_api_service.dart @@ -0,0 +1,227 @@ +import 'dart:developer'; +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import '../models/storage.dart'; +import 'auth_manager.dart'; +import '../pages/auth/login_page.dart'; + +class StorageApiService { + static final Dio _dio = Dio(); + + static Future _ensureAuthenticated() async { + final authManager = AuthManager.instance; + if (!authManager.isLoggedIn.value) { + // 需要登录 + await Get.to(() => const LoginPage()); + return authManager.isLoggedIn.value; + } + return true; + } + + static Future _getOptions() async { + if (!await _ensureAuthenticated()) { + throw Exception('用户未登录'); + } + return await AuthManager.instance.getAuthOptions(); + } + + static String get _baseUrl => AuthManager.instance.baseUrl ?? 'http://localhost:5244'; + + static Future>> getStorages({ + int page = 1, + int perPage = 10, + }) async { + try { + final options = await _getOptions(); + final response = await _dio.post( + '$_baseUrl/api/admin/storage/list', + data: { + 'page': page, + 'per_page': perPage, + }, + options: options, + ); + + if (response.data['code'] == 200) { + final pageResponse = PageResponse.fromJson( + response.data['data'], + (json) => Storage.fromJson(json), + ); + return ApiResponse( + code: response.data['code'], + message: response.data['message'], + data: pageResponse, + ); + } else { + return ApiResponse( + code: response.data['code'], + message: response.data['message'], + ); + } + } catch (e) { + log('Failed to get storages: $e'); + return ApiResponse( + code: -1, + message: e.toString(), + ); + } + } + + static Future>> createStorage(Storage storage) async { + try { + final options = await _getOptions(); + final response = await _dio.post( + '$_baseUrl/api/admin/storage/create', + data: storage.toJson(), + options: options, + ); + + return ApiResponse( + code: response.data['code'], + message: response.data['message'], + data: response.data['data'] as Map?, + ); + } catch (e) { + log('Failed to create storage: $e'); + return ApiResponse( + code: -1, + message: e.toString(), + ); + } + } + + static Future> updateStorage(Storage storage) async { + try { + final options = await _getOptions(); + final response = await _dio.post( + '$_baseUrl/api/admin/storage/update', + data: storage.toJson(), + options: options, + ); + + return ApiResponse( + code: response.data['code'], + message: response.data['message'], + ); + } catch (e) { + log('Failed to update storage: $e'); + return ApiResponse( + code: -1, + message: e.toString(), + ); + } + } + + static Future> deleteStorage(int id) async { + try { + final options = await _getOptions(); + final response = await _dio.post( + '$_baseUrl/api/admin/storage/delete?id=$id', + options: options, + ); + + return ApiResponse( + code: response.data['code'], + message: response.data['message'], + ); + } catch (e) { + log('Failed to delete storage: $e'); + return ApiResponse( + code: -1, + message: e.toString(), + ); + } + } + + static Future> enableStorage(int id) async { + try { + final options = await _getOptions(); + final response = await _dio.post( + '$_baseUrl/api/admin/storage/enable?id=$id', + options: options, + ); + + return ApiResponse( + code: response.data['code'], + message: response.data['message'], + ); + } catch (e) { + log('Failed to enable storage: $e'); + return ApiResponse( + code: -1, + message: e.toString(), + ); + } + } + + static Future> disableStorage(int id) async { + try { + final options = await _getOptions(); + final response = await _dio.post( + '$_baseUrl/api/admin/storage/disable?id=$id', + options: options, + ); + + return ApiResponse( + code: response.data['code'], + message: response.data['message'], + ); + } catch (e) { + log('Failed to disable storage: $e'); + return ApiResponse( + code: -1, + message: e.toString(), + ); + } + } + + static Future> getStorage(int id) async { + try { + final options = await _getOptions(); + final response = await _dio.get( + '$_baseUrl/api/admin/storage/get?id=$id', + options: options, + ); + + if (response.data['code'] == 200) { + return ApiResponse( + code: response.data['code'], + message: response.data['message'], + data: Storage.fromJson(response.data['data']), + ); + } else { + return ApiResponse( + code: response.data['code'], + message: response.data['message'], + ); + } + } catch (e) { + log('Failed to get storage: $e'); + return ApiResponse( + code: -1, + message: e.toString(), + ); + } + } + + static Future> loadAllStorages() async { + try { + final options = await _getOptions(); + final response = await _dio.post( + '$_baseUrl/api/admin/storage/load_all', + options: options, + ); + + return ApiResponse( + code: response.data['code'], + message: response.data['message'], + ); + } catch (e) { + log('Failed to load all storages: $e'); + return ApiResponse( + code: -1, + message: e.toString(), + ); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 07d08e1..3d0b327 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -669,6 +669,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 75fcce8..1e895d5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: open_filex: ^4.3.4 flutter_local_notifications: ^19.3.1 open_file_manager: ^2.0.1 + shared_preferences: ^2.2.2 dev_dependencies: