diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c972b0e..d8177eb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,11 @@ "Bash(flutter analyze *)", "Bash(python _fix_script.py)", "Bash(dart analyze *)", - "Bash(dart run *)" + "Bash(dart run *)", + "Bash(python -c \"import py_compile; py_compile.compile\\('app.py', doraise=True\\); print\\('OK'\\)\")", + "Bash(python -c \"import py_compile; py_compile.compile\\('server/app.py', doraise=True\\); print\\('Python OK'\\)\")", + "Bash(python -c \"import py_compile; py_compile.compile\\('D:/UserData/Desktop/my_proj/mooknote/server/app.py', doraise=True\\); print\\('OK'\\)\")", + "Bash(python -c \"import app; print\\('OK'\\)\")" ] } } diff --git a/.gitignore b/.gitignore index ad57871..9c750ca 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ coverage/ # Temporary files *.tmp *.temp + +# Server +/server/ diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index fbffc46..12c3d15 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -53,7 +53,15 @@ class _BookTabPageState extends State { } if (books.isEmpty) { - return _buildEmptyState(context, provider.bookStatusIndex); + return RefreshIndicator( + onRefresh: () async => await provider.loadBooks(), + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [_buildEmptyState(context, provider.bookStatusIndex)], + ), + ); } if (_layoutStyle == 1) { diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index e3532e7..b8d8009 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -57,7 +57,15 @@ class _MovieTabPageState extends State { final movies = provider.getMoviesByStatus(currentStatus); if (movies.isEmpty) { - return _buildEmptyState(context, provider.movieStatusIndex); + return RefreshIndicator( + onRefresh: () async => await provider.loadMovies(), + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [_buildEmptyState(context, provider.movieStatusIndex)], + ), + ); } if (_layoutStyle == 1) { diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart index d70a7a5..e96720d 100644 --- a/lib/pages/note/note_detail_page.dart +++ b/lib/pages/note/note_detail_page.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import 'note_share_page.dart'; +import '../../widgets/fade_in_local_image.dart'; /// 笔记详情页 class NoteDetailPage extends StatefulWidget { @@ -232,12 +233,10 @@ class _NoteDetailPageState extends State { for (final imgPath in note.images) { if (imgPath.contains(path) || path.contains(imgPath)) { - if (File(imgPath).existsSync()) { return ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.file(File(imgPath), fit: BoxFit.cover), + child: FadeInLocalImage(path: imgPath, fit: BoxFit.cover), ); - } } } @@ -265,7 +264,7 @@ class _NoteDetailPageState extends State { boundaryMargin: const EdgeInsets.all(20), minScale: 0.5, maxScale: 4, - child: Image.file(File(images[initialIndex]), fit: BoxFit.contain), + child: FadeInLocalImage(path: images[initialIndex], fit: BoxFit.contain), ), ), ), diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index a64ecce..0a616ff 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -6,6 +6,7 @@ import '../../models/data_models.dart'; import '../../utils/user_prefs.dart'; import '../../widgets/note_list_item.dart'; import '../../widgets/shimmer_skeleton.dart'; +import '../../widgets/fade_in_local_image.dart'; /// 笔记标签页 class NoteTabPage extends StatefulWidget { @@ -121,7 +122,15 @@ class _NoteTabPageState extends State { } if (allNotes.isEmpty && _displayedNotes.isEmpty) { - return _buildEmptyState(context); + return RefreshIndicator( + onRefresh: _refresh, + color: const Color(0xFF1A1A1A), + backgroundColor: Colors.white, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [_buildEmptyState(context)], + ), + ); } if (_layoutStyle == 1) { @@ -425,11 +434,10 @@ class _NoteTabPageState extends State { children: [ ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(10)), - child: Image.file( - File(images.first), - width: double.infinity, + child: FadeInLocalImage( + path: images.first, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), + errorWidget: const SizedBox.shrink(), ), ), if (extraCount > 0) diff --git a/lib/pages/sync/cloud_sync_page.dart b/lib/pages/sync/cloud_sync_page.dart index 2184350..7a7ae01 100644 --- a/lib/pages/sync/cloud_sync_page.dart +++ b/lib/pages/sync/cloud_sync_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'webdav_sync_page.dart'; +import 'server_sync_page.dart'; /// 云备份主页面 - 选择备份方式 class CloudSyncPage extends StatelessWidget { @@ -8,225 +9,94 @@ class CloudSyncPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - title: const Text('云备份'), - ), + backgroundColor: const Color(0xFFF8F8F8), + appBar: AppBar(title: const Text('云备份')), body: ListView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(20), children: [ - // 备份方式标题 _buildSectionTitle('选择备份方式'), - const SizedBox(height: 16), - - // WebDAV 备份选项 - _buildSyncOption( - context, + const SizedBox(height: 12), + _buildOption( icon: Icons.storage_outlined, title: 'WebDAV 备份', subtitle: '通过 WebDAV 协议备份到个人云盘', - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const WebDAVSyncPage()), - ); - }, + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WebDAVSyncPage())), ), - - const SizedBox(height: 32), - - // 说明文字 - _buildInfoSection(), + const SizedBox(height: 12), + _buildOption( + icon: Icons.sync_outlined, + title: '服务端实时同步', + subtitle: '自建服务端,多设备数据实时同步', + enabled: false, + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ServerSyncPage())), + ), + const SizedBox(height: 28), + _buildInfo(), ], ), ); } - /// 构建区块标题 Widget _buildSectionTitle(String title) { - return Row( - children: [ - Container( - width: 4, - height: 16, - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - Text( - title, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Color(0xFF1A1A1A), - ), - ), - ], - ); + return Row(children: [ + Container(width: 3, height: 14, decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ]); } - /// 构建信息说明区域 - Widget _buildInfoSection() { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFFF8F8F8), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: - Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - child: const Icon( - Icons.info_outline, - size: 18, - color: Color(0xFF666666), - ), - ), - const SizedBox(width: 12), - const Text( - '关于云备份', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Color(0xFF1A1A1A), - ), - ), - ], - ), - const SizedBox(height: 16), - _buildInfoItem('云备份可以将您的数据备份到远程服务器'), - const SizedBox(height: 10), - _buildInfoItem('支持多台设备之间的数据恢复'), - const SizedBox(height: 10), - _buildInfoItem('建议定期进行云备份以确保数据安全'), - const SizedBox(height: 10), - _buildInfoItem('首次备份可能需要较长时间,请保持网络连接'), - ], - ), - ); - } - - /// 构建信息项 - Widget _buildInfoItem(String text) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 6, - height: 6, - margin: const EdgeInsets.only(top: 7), - decoration: BoxDecoration( - color: const Color(0xFF999999), - borderRadius: BorderRadius.circular(3), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - text, - style: const TextStyle( - fontSize: 13, - color: Color(0xFF666666), - height: 1.5, - ), - ), - ), - ], - ); - } - - Widget _buildSyncOption( - BuildContext context, { - required IconData icon, - required String title, - required String subtitle, - required VoidCallback onTap, - bool enabled = true, - }) { + Widget _buildOption({required IconData icon, required String title, required String subtitle, required VoidCallback onTap, bool enabled = true}) { return GestureDetector( onTap: enabled ? onTap : null, child: Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(18), decoration: BoxDecoration( - color: enabled ? const Color(0xFFFAFAFA) : const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: enabled ? const Color(0xFFE8E8E8) : const Color(0xFFEEEEEE), - width: 0.5, - ), - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: enabled ? Colors.white : const Color(0xFFEEEEEE), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: enabled - ? const Color(0xFFE8E8E8) - : const Color(0xFFEEEEEE), - width: 0.5, - ), - ), - child: Icon( - icon, - color: - enabled ? const Color(0xFF666666) : const Color(0xFF999999), - size: 22, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: enabled - ? const Color(0xFF1A1A1A) - : const Color(0xFF999999), - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: TextStyle( - fontSize: 13, - color: enabled - ? const Color(0xFF666666) - : const Color(0xFF999999), - height: 1.4, - ), - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: - enabled ? const Color(0xFFCCCCCC) : const Color(0xFFE5E5E5), - ), - ], + color: enabled ? Colors.white : const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(14), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6, offset: const Offset(0, 2))], ), + child: Row(children: [ + Container(width: 44, height: 44, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10)), child: Icon(icon, color: enabled ? const Color(0xFF666666) : const Color(0xFFBBBBBB), size: 22)), + const SizedBox(width: 14), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: enabled ? const Color(0xFF1A1A1A) : const Color(0xFFBBBBBB))), + const SizedBox(height: 3), + Text(subtitle, style: TextStyle(fontSize: 12, color: enabled ? const Color(0xFF999999) : const Color(0xFFCCCCCC))), + ])), + Icon(Icons.chevron_right, color: enabled ? const Color(0xFFCCCCCC) : const Color(0xFFE5E5E5)), + ]), ), ); } + + Widget _buildInfo() { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6, offset: const Offset(0, 2))], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(8)), child: const Icon(Icons.info_outline, size: 18, color: Color(0xFF666666))), + const SizedBox(width: 10), + const Text('使用说明', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ]), + const SizedBox(height: 14), + _infoItem('WebDAV 备份:将数据备份到支持 WebDAV 的云盘'), + const SizedBox(height: 8), + _infoItem('服务端实时同步:通过自建服务端实现多设备实时同步'), + const SizedBox(height: 8), + _infoItem('激活码由服务端管理员在管理后台生成'), + const SizedBox(height: 8), + _infoItem('建议定期备份 + 实时同步配合使用'), + ]), + ); + } + + Widget _infoItem(String text) { + return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container(width: 5, height: 5, margin: const EdgeInsets.only(top: 5), decoration: BoxDecoration(color: const Color(0xFFBBBBBB), shape: BoxShape.circle)), + const SizedBox(width: 10), + Expanded(child: Text(text, style: const TextStyle(fontSize: 12, color: Color(0xFF888888), height: 1.5))), + ]); + } } diff --git a/lib/pages/sync/server_sync_page.dart b/lib/pages/sync/server_sync_page.dart new file mode 100644 index 0000000..4c3034d --- /dev/null +++ b/lib/pages/sync/server_sync_page.dart @@ -0,0 +1,323 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/app_provider.dart'; +import '../../utils/user_prefs.dart'; +import '../../utils/sync/server_sync_service.dart'; +import '../../utils/sync/server_data_service.dart'; +import '../../utils/toast_util.dart'; + +/// 服务端实时同步页面 +class ServerSyncPage extends StatefulWidget { + const ServerSyncPage({super.key}); + + @override + State createState() => _ServerSyncPageState(); +} + +class _ServerSyncPageState extends State { + final UserPrefs _prefs = UserPrefs(); + final _urlController = TextEditingController(); + final _codeController = TextEditingController(); + + bool _syncEnabled = false; + bool _isActivated = false; + bool _isChecking = false; + String _expiresText = ''; + Timer? _statusTimer; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + @override + void dispose() { + _urlController.dispose(); + _codeController.dispose(); + _statusTimer?.cancel(); + super.dispose(); + } + + void _loadSettings() { + final url = _prefs.syncServerUrl; + final code = _prefs.syncActivationCode; + _urlController.text = url; + _codeController.text = code; + _isActivated = url.isNotEmpty && code.isNotEmpty; + _syncEnabled = _isActivated && _prefs.syncEnabled; + _updateExpiresText(); + if (_isActivated) _startStatusPolling(); + } + + void _updateExpiresText() { + if (_prefs.syncIsPermanent) { + _expiresText = '永久有效'; + } else { + final exp = _prefs.syncExpiresAt; + if (exp.isNotEmpty) { + try { + final dt = DateTime.parse(exp); + _expiresText = '有效期至 ${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } catch (_) { + _expiresText = '有效期至 $exp'; + } + } else { + _expiresText = ''; + } + } + } + + void _startStatusPolling() { + _statusTimer?.cancel(); + _statusTimer = Timer.periodic(const Duration(minutes: 1), (_) => _checkStatus()); + } + + Future _checkStatus() async { + if (!_isActivated) return; + final result = await ServerSyncService.instance.checkActivation(); + if (!mounted) return; + if (result == null || result['valid'] != true) { + await _prefs.setSyncEnabled(false); + setState(() { + _isActivated = false; + _syncEnabled = false; + _expiresText = '激活码已失效'; + }); + if (mounted) ToastUtil.show(context, '激活码已失效,同步已关闭'); + } else { + await _prefs.setSyncExpiresAt(result['expires_at'] ?? ''); + await _prefs.setSyncIsPermanent(result['is_permanent'] == true); + _updateExpiresText(); + } + } + + Future _checkActivation() async { + final url = _urlController.text.trim(); + final code = _codeController.text.trim().toUpperCase(); + if (url.isEmpty || code.isEmpty) { + ToastUtil.show(context, '请输入服务器地址和激活码'); + return; + } + setState(() => _isChecking = true); + await _prefs.setSyncServerUrl(url); + await _prefs.setSyncActivationCode(code); + + final result = await ServerSyncService.instance.checkActivation(); + if (!mounted) return; + setState(() => _isChecking = false); + + if (result != null && result['valid'] == true) { + _isActivated = true; + await _prefs.setSyncExpiresAt(result['expires_at'] ?? ''); + await _prefs.setSyncIsPermanent(result['is_permanent'] == true); + _updateExpiresText(); + _startStatusPolling(); + await _prefs.setSyncEnabled(true); + _syncEnabled = true; + await ServerSyncService.instance.uploadToServer(); + if (mounted) ToastUtil.show(context, '激活成功,实时同步已开启'); + } else { + final error = result?['error'] ?? '激活失败'; + if (mounted) ToastUtil.show(context, error.toString()); + } + } + + Future _toggleSync(bool value) async { + await _prefs.setSyncEnabled(value); + setState(() => _syncEnabled = value); + + if (value && _isActivated) { + await ServerSyncService.instance.uploadToServer(); + final provider = context.read(); + await provider.loadMovies(); + await provider.loadBooks(); + await provider.loadNotes(); + if (mounted) ToastUtil.show(context, '已切换到服务端数据'); + } else { + // 关闭同步:从服务端下载最新数据到本地 + if (mounted) ToastUtil.show(context, '正在从服务端同步数据...'); + final success = await ServerSyncService.instance.downloadToLocal(); + if (mounted) { + if (success) { + final provider = context.read(); + await provider.loadMovies(); + await provider.loadBooks(); + await provider.loadNotes(); + ToastUtil.show(context, '数据已下载到本地'); + } else { + ToastUtil.show(context, '下载失败,使用本地数据'); + } + } + } + } + + Future _disconnect() async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('断开连接', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + content: const Text('将清除服务器配置和激活信息,确定要断开吗?', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消', style: TextStyle(color: Color(0xFF999999)))), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定', style: TextStyle(color: Color(0xFFE53935)))), + ], + ), + ); + if (confirm != true) return; + + _statusTimer?.cancel(); + await _toggleSync(false); + await _prefs.setSyncServerUrl(''); + await _prefs.setSyncActivationCode(''); + await _prefs.setSyncExpiresAt(''); + await _prefs.setSyncIsPermanent(false); + await _prefs.setSyncEnabled(false); + + setState(() { + _isActivated = false; + _syncEnabled = false; + _expiresText = ''; + _urlController.clear(); + _codeController.clear(); + }); + if (mounted) ToastUtil.show(context, '已断开连接'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8F8F8), + appBar: AppBar(title: const Text('服务端实时同步')), + body: ListView(padding: const EdgeInsets.all(20), children: [ + // 状态卡片 + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container(width: 10, height: 10, decoration: BoxDecoration( + color: _isActivated ? const Color(0xFF66BB6A) : const Color(0xFFDDDDDD), shape: BoxShape.circle)), + const SizedBox(width: 10), + Text(_isActivated ? '已激活' : '未激活', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, + color: _isActivated ? const Color(0xFF66BB6A) : const Color(0xFFBBBBBB))), + const Spacer(), + if (_isActivated) + GestureDetector( + onTap: _disconnect, + child: Container(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(6)), + child: const Text('断开', style: TextStyle(fontSize: 12, color: Color(0xFFE57373)))), + ), + ]), + const SizedBox(height: 20), + const Text('服务器地址', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), + const SizedBox(height: 6), + TextField(controller: _urlController, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + decoration: _inputDeco('例: http://192.168.1.100:5000')), + const SizedBox(height: 14), + const Text('激活码', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), + const SizedBox(height: 6), + TextField(controller: _codeController, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), + textCapitalization: TextCapitalization.characters, decoration: _inputDeco('例: MK-A1B2C3D4E5F6')), + const SizedBox(height: 16), + SizedBox(width: double.infinity, + child: ElevatedButton( + onPressed: _isChecking ? null : _checkActivation, + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF1A1A1A), foregroundColor: Colors.white, + disabledBackgroundColor: const Color(0xFFDDDDDD), elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(vertical: 13)), + child: _isChecking + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : Text(_isActivated ? '重新验证' : '验证激活', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + ), + ), + if (_expiresText.isNotEmpty) ...[ + const SizedBox(height: 12), + Center(child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.access_time, size: 14, color: _prefs.syncIsPermanent ? const Color(0xFF66BB6A) : const Color(0xFFFF9800)), + const SizedBox(width: 6), + Text(_expiresText, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, + color: _prefs.syncIsPermanent ? const Color(0xFF66BB6A) : const Color(0xFFFF9800))), + ])), + ], + ]), + ), + const SizedBox(height: 16), + // 同步开关 + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))]), + child: Row(children: [ + Container(width: 44, height: 44, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10)), + child: Icon(_syncEnabled ? Icons.sync : Icons.sync_disabled, + color: _syncEnabled ? const Color(0xFF1A1A1A) : const Color(0xFFCCCCCC), size: 22)), + const SizedBox(width: 14), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('服务端实时同步', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A))), + const SizedBox(height: 2), + Text(_syncEnabled ? '使用服务端数据,多设备实时共享' : '关闭后下载数据到本地使用', + style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))), + ])), + Switch(value: _syncEnabled, onChanged: _isActivated ? _toggleSync : null, + activeColor: const Color(0xFF1A1A1A), activeTrackColor: const Color(0xFF1A1A1A).withOpacity(0.3), + inactiveThumbColor: Colors.white, inactiveTrackColor: const Color(0xFFE5E5E5)), + ]), + ), + const SizedBox(height: 24), + // 说明 + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6, offset: const Offset(0, 2))]), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(8)), + child: const Icon(Icons.info_outline, size: 18, color: Color(0xFF666666))), + const SizedBox(width: 10), + const Text('使用说明', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ]), + const SizedBox(height: 14), + _infoItem('1. 在服务端管理后台生成激活码'), + const SizedBox(height: 8), + _infoItem('2. 输入服务器地址和激活码完成验证'), + const SizedBox(height: 8), + _infoItem('3. 验证通过后自动开启实时同步'), + const SizedBox(height: 8), + _infoItem('4. 开启时所有数据通过服务端接口操作'), + const SizedBox(height: 8), + _infoItem('5. 关闭时从服务端下载数据到本地使用'), + ]), + ), + const SizedBox(height: 40), + ]), + ); + } + + InputDecoration _inputDeco(String hint) { + return InputDecoration( + hintText: hint, hintStyle: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)), + filled: true, fillColor: const Color(0xFFF8F8F8), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFF1A1A1A), width: 1)), + ); + } + + Widget _infoItem(String text) { + return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container(width: 5, height: 5, margin: const EdgeInsets.only(top: 6), decoration: BoxDecoration(color: const Color(0xFFBBBBBB), shape: BoxShape.circle)), + const SizedBox(width: 10), + Expanded(child: Text(text, style: const TextStyle(fontSize: 13, color: Color(0xFF888888), height: 1.5))), + ]); + } +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 0006c45..ca2b4be 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -11,6 +11,7 @@ import '../utils/tag/tag_dao.dart'; import '../utils/database_helper.dart'; import '../utils/image_path_helper.dart'; import '../utils/user_prefs.dart'; +import '../utils/sync/server_data_service.dart'; /// 应用全局状态管理 class AppProvider extends ChangeNotifier { @@ -37,6 +38,15 @@ class AppProvider extends ChangeNotifier { // 底部导航栏是否可见 bool _bottomNavVisible = true; + + /// 是否使用远程服务端(同步开关 + 已激活) + bool get _useRemote { + final prefs = UserPrefs(); + return prefs.syncEnabled && + prefs.syncServerUrl.isNotEmpty && + prefs.syncActivationCode.isNotEmpty && + ServerDataService.instance.isAvailable; + } // 观影选中的状态 (0: 已看,1: 想看,2: 在看) int _movieStatusIndex = 0; @@ -86,18 +96,33 @@ class AppProvider extends ChangeNotifier { // 加载影视数据 Future loadMovies() async { + if (_useRemote) { + _movies = await ServerDataService.instance.getMovies(); + notifyListeners(); + return; + } _movies = await _movieDao.getAllMovies(); notifyListeners(); } // 加载书籍数据 Future loadBooks() async { + if (_useRemote) { + _books = await ServerDataService.instance.getBooks(); + notifyListeners(); + return; + } _books = await _bookDao.getAllBooks(); notifyListeners(); } // 加载笔记数据 Future loadNotes() async { + if (_useRemote) { + _notes = await ServerDataService.instance.getNotes(); + notifyListeners(); + return; + } _notes = await _noteDao.getAllNotes(); notifyListeners(); } @@ -162,60 +187,101 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + // ─── 图片上传辅助 ──────────────────────────────────────────────── + + Future _uploadImagesIfRemote(List paths) async { + if (!_useRemote) return; + final valid = paths.where((p) => p != null && p!.isNotEmpty).cast().toList(); + if (valid.isNotEmpty) { + await ServerDataService.uploadLocalImages(valid); + } + } + // 添加影视记录 Future addMovie(Movie movie) async { - await _movieDao.insertMovie(movie); + if (_useRemote) { + await ServerDataService.instance.saveMovie(movie); + } else { + await _movieDao.insertMovie(movie); + } + await _uploadImagesIfRemote([movie.posterPath]); await loadMovies(); } - - // 更新影视记录 + Future updateMovie(Movie movie) async { - await _movieDao.updateMovie(movie); + if (_useRemote) { + await ServerDataService.instance.saveMovie(movie); + } else { + await _movieDao.updateMovie(movie); + } + await _uploadImagesIfRemote([movie.posterPath]); await loadMovies(); } - - // 删除影视记录(软删除,移入回收站) - // 注意:软删除时不删除图片文件,恢复时文件仍然存在 + Future removeMovie(String id) async { - await _movieDao.deleteMovie(id); + if (_useRemote) { + await ServerDataService.instance.deleteMovie(id); + } else { + await _movieDao.deleteMovie(id); + } await loadMovies(); } - - // 添加书籍记录 + Future addBook(Book book) async { - await _bookDao.insertBook(book); + if (_useRemote) { + await ServerDataService.instance.saveBook(book); + } else { + await _bookDao.insertBook(book); + } + await _uploadImagesIfRemote([book.coverPath]); await loadBooks(); } - - // 更新书籍记录 + Future updateBook(Book book) async { - await _bookDao.updateBook(book); + if (_useRemote) { + await ServerDataService.instance.saveBook(book); + } else { + await _bookDao.updateBook(book); + } + await _uploadImagesIfRemote([book.coverPath]); await loadBooks(); } - - // 删除书籍记录(软删除,移入回收站) - // 注意:软删除时不删除图片文件,恢复时文件仍然存在 + Future removeBook(String id) async { - await _bookDao.deleteBook(id); + if (_useRemote) { + await ServerDataService.instance.deleteBook(id); + } else { + await _bookDao.deleteBook(id); + } await loadBooks(); } - - // 添加笔记 + Future addNote(Note note) async { - await _noteDao.insertNote(note); + if (_useRemote) { + await ServerDataService.instance.saveNote(note); + } else { + await _noteDao.insertNote(note); + } + await _uploadImagesIfRemote(note.images); await loadNotes(); } - - // 更新笔记 + Future updateNote(Note note) async { - await _noteDao.updateNote(note); + if (_useRemote) { + await ServerDataService.instance.saveNote(note); + } else { + await _noteDao.updateNote(note); + } + await _uploadImagesIfRemote(note.images); await loadNotes(); } - - // 删除笔记(软删除,移入回收站) - // 注意:软删除时不删除图片文件,恢复时文件仍然存在 + Future removeNote(String id) async { - await _noteDao.deleteNote(id); + if (_useRemote) { + await ServerDataService.instance.deleteNote(id); + } else { + await _noteDao.deleteNote(id); + } await loadNotes(); } diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index 1cb2c01..a7ab622 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -9,6 +9,12 @@ class DatabaseHelper { DatabaseHelper._init(); + /// 数据库文件路径 + Future get databasePath async { + final path = await getDatabasesPath(); + return join(path, 'mooknote.db'); + } + /// 重新打开数据库(用于 WebDAV 同步后) Future reopenDatabase() async { // 关闭现有连接 @@ -566,7 +572,15 @@ class DatabaseHelper { // 关闭数据库 Future close() async { - final db = await instance.database; - db.close(); + if (_database != null) { + await _database!.close(); + _database = null; + } + } + + // 重新打开(关闭后重新初始化) + Future reopen() async { + await close(); + await database; } } diff --git a/lib/utils/sync/server_data_service.dart b/lib/utils/sync/server_data_service.dart new file mode 100644 index 0000000..af47901 --- /dev/null +++ b/lib/utils/sync/server_data_service.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; +import '../../models/data_models.dart'; +import '../user_prefs.dart'; + +/// 服务端数据服务 - 所有数据操作通过远程 API +class ServerDataService { + static final ServerDataService instance = ServerDataService._(); + ServerDataService._(); + + final UserPrefs _prefs = UserPrefs(); + + String get _baseUrl => _prefs.syncServerUrl; + String get _code => _prefs.syncActivationCode; + + Map get _headers => {'Content-Type': 'application/json'}; + + Map _body([Map? extra]) { + return {'code': _code, ...?extra}; + } + + bool get isAvailable => _baseUrl.isNotEmpty && _code.isNotEmpty; + + Future _post(String path, [Map? extra]) async { + final resp = await http.post( + Uri.parse('$_baseUrl$path'), + headers: _headers, + body: jsonEncode(_body(extra)), + ).timeout(const Duration(seconds: 30)); + if (resp.statusCode != 200) return null; + return jsonDecode(resp.body); + } + + // ─── 影视 ──────────────────────────────────────────────────── + + Future> getMovies() async { + final data = await _post('/api/data/movies'); + if (data == null || data['movies'] == null) return []; + return (data['movies'] as List).map((m) => Movie.fromJson(m as Map)).toList(); + } + + Future saveMovie(Movie movie) async { + final data = await _post('/api/data/movie/save', {'movie': movie.toJson()}); + return data != null; + } + + Future deleteMovie(String id) async { + final data = await _post('/api/data/movie/delete', {'id': id}); + return data != null; + } + + // ─── 书籍 ──────────────────────────────────────────────────── + + Future> getBooks() async { + final data = await _post('/api/data/books'); + if (data == null || data['books'] == null) return []; + return (data['books'] as List).map((b) => Book.fromJson(b as Map)).toList(); + } + + Future saveBook(Book book) async { + final data = await _post('/api/data/book/save', {'book': book.toJson()}); + return data != null; + } + + Future deleteBook(String id) async { + final data = await _post('/api/data/book/delete', {'id': id}); + return data != null; + } + + // ─── 笔记 ──────────────────────────────────────────────────── + + Future> getNotes() async { + final data = await _post('/api/data/notes'); + if (data == null || data['notes'] == null) return []; + return (data['notes'] as List).map((n) => Note.fromJson(n as Map)).toList(); + } + + Future saveNote(Note note) async { + final data = await _post('/api/data/note/save', {'note': note.toJson()}); + return data != null; + } + + Future deleteNote(String id) async { + final data = await _post('/api/data/note/delete', {'id': id}); + return data != null; + } + + // ─── 标签 ──────────────────────────────────────────────────── + + Future>> getTags(String? type) async { + final data = await _post('/api/data/tags', type != null ? {'type': type} : null); + if (data == null || data['tags'] == null) return []; + return (data['tags'] as List).map((t) => Map.from(t as Map)).toList(); + } + + Future saveTag(String name, String type) async { + final data = await _post('/api/data/tag/save', {'tag': {'name': name, 'type': type}}); + return data != null; + } + + Future deleteTag(String id) async { + final data = await _post('/api/data/tag/delete', {'id': id}); + return data != null; + } + + // ─── 图片 ──────────────────────────────────────────────────── + + /// 是否激活(AppProvider 也会用这个检查) + static bool get isActive { + final p = UserPrefs(); + return p.syncEnabled && p.syncServerUrl.isNotEmpty && p.syncActivationCode.isNotEmpty; + } + + /// 将本地路径转为服务端图片 URL + static Future toImageUrl(String localPath) async { + if (!isActive) return localPath; + final appDir = (await getApplicationDocumentsDirectory()).path; + final relPath = p.relative(localPath, from: appDir).replaceAll('\\', '/'); + final prefs = UserPrefs(); + return '${prefs.syncServerUrl}/api/data/image/${prefs.syncActivationCode}/$relPath'; + } + + /// 批量上传图片到服务端(自动计算相对路径) + static Future uploadLocalImages(List filePaths) async { + if (!isActive || filePaths.isEmpty) return; + final result = await instance.uploadImages(filePaths); + debugPrint('[Sync] 上传 ${result.length}/${filePaths.length} 张图片'); + } + + /// 上传单张图片到服务端 + static Future uploadLocalImage(String filePath) async { + if (!isActive || filePath.isEmpty) return; + final result = await instance.uploadImage(filePath); + debugPrint('[Sync] 上传图片: ${result ?? "失败"}'); + } + + String imageUrl(String relPath) { + return '$_baseUrl/api/data/image/$_code/$relPath'; + } + + Future> uploadImages(List filePaths) async { + final request = http.MultipartRequest('POST', Uri.parse('$_baseUrl/api/data/image/upload')); + request.fields['code'] = _code; + final appDir = (await getApplicationDocumentsDirectory()).path; + for (final path in filePaths) { + final relPath = p.relative(path, from: appDir).replaceAll('\\', '/'); + final file = File(path); + request.files.add(await http.MultipartFile( + 'images', file.readAsBytes().asStream(), await file.length(), + filename: relPath, + )); + } + final resp = await request.send().timeout(const Duration(seconds: 60)); + if (resp.statusCode != 200) return []; + final body = await resp.stream.bytesToString(); + final data = jsonDecode(body) as Map; + return (data['files'] as List?)?.cast() ?? []; + } + + Future uploadImage(String filePath) async { + final files = await uploadImages([filePath]); + return files.isNotEmpty ? files.first : null; + } +} diff --git a/lib/utils/sync/server_sync_service.dart b/lib/utils/sync/server_sync_service.dart new file mode 100644 index 0000000..497a474 --- /dev/null +++ b/lib/utils/sync/server_sync_service.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; +import '../user_prefs.dart'; +import '../database_helper.dart'; + +/// 服务端实时同步服务 +/// - 开启时:上传一次本地数据到服务器,后续 CRUD 走 API +/// - 关闭时:从服务器下载数据到本地,切换本地数据库 +class ServerSyncService { + static final ServerSyncService instance = ServerSyncService._(); + ServerSyncService._(); + + final UserPrefs _prefs = UserPrefs(); + bool _isSyncing = false; + + bool get isConfigured { + return _prefs.syncServerUrl.isNotEmpty && _prefs.syncActivationCode.isNotEmpty; + } + + Future?> checkActivation() async { + final url = _prefs.syncServerUrl; + final code = _prefs.syncActivationCode; + final deviceId = _prefs.deviceId; + if (url.isEmpty || code.isEmpty || deviceId.isEmpty) return null; + try { + final resp = await http.post( + Uri.parse('$url/api/activate'), + headers: {'Content-Type': 'application/json'}, + body: '{"code":"$code","device_id":"$deviceId"}', + ).timeout(const Duration(seconds: 5)); + return resp.statusCode == 200 + ? _jsonDecode(resp.body) + : {'valid': false, 'error': '激活码无效'}; + } catch (_) { + return {'valid': false, 'error': '无法连接服务器'}; + } + } + + Map? _jsonDecode(String s) { + try { final d = jsonDecode(s); return d is Map ? d : null; } catch (_) { return null; } + } + + /// 开启同步:上传本地数据到服务器 + Future uploadToServer() async { + if (!isConfigured || _isSyncing) return false; + _isSyncing = true; + try { + final url = _prefs.syncServerUrl; + final code = _prefs.syncActivationCode; + final deviceId = _prefs.deviceId; + + final dbPath = await DatabaseHelper.instance.databasePath; + if (dbPath == null || !File(dbPath).existsSync()) { + debugPrint('[Sync] 数据库文件不存在'); + return false; + } + + final request = http.MultipartRequest('POST', Uri.parse('$url/api/sync/upload')); + request.fields['code'] = code; + request.fields['device_id'] = deviceId; + request.files.add(await http.MultipartFile.fromPath('database', dbPath)); + + final appDir = await getApplicationDocumentsDirectory(); + final imgDir = Directory(p.join(appDir.path, 'images')); + if (await imgDir.exists()) { + await for (final entity in imgDir.list(recursive: true)) { + if (entity is File) { + final relPath = p.relative(entity.path, from: appDir.path).replaceAll('\\', '/'); + request.files.add(await http.MultipartFile('images', entity.readAsBytes().asStream(), await entity.length(), filename: relPath)); + } + } + } + + final avatarsDir = Directory(p.join(appDir.path, 'avatars')); + if (await avatarsDir.exists()) { + await for (final entity in avatarsDir.list()) { + if (entity is File) { + final relPath = p.relative(entity.path, from: appDir.path).replaceAll('\\', '/'); + request.files.add(await http.MultipartFile('images', entity.readAsBytes().asStream(), await entity.length(), filename: relPath)); + } + } + } + + final resp = await request.send().timeout(const Duration(seconds: 300)); + if (resp.statusCode == 200) { + debugPrint('[Sync] 上传成功'); + return true; + } + debugPrint('[Sync] 上传失败 HTTP ${resp.statusCode}'); + } catch (e) { + debugPrint('[Sync] 上传异常: $e'); + } finally { + _isSyncing = false; + } + return false; + } + + /// 关闭同步:从服务器下载数据到本地 + Future downloadToLocal() async { + if (!isConfigured || _isSyncing) return false; + _isSyncing = true; + try { + final url = _prefs.syncServerUrl; + final code = _prefs.syncActivationCode; + final deviceId = _prefs.deviceId; + + final infoResp = await http.post( + Uri.parse('$url/api/sync/info'), + headers: {'Content-Type': 'application/json'}, + body: '{"code":"$code","device_id":"$deviceId"}', + ).timeout(const Duration(seconds: 15)); + if (infoResp.statusCode != 200) return false; + + final info = _jsonDecode(infoResp.body); + if (info == null || info['has_backup'] != true) return false; + + final dbResp = await http.post( + Uri.parse('$url/api/sync/download/database'), + headers: {'Content-Type': 'application/json'}, + body: '{"code":"$code"}', + ).timeout(const Duration(seconds: 120)); + if (dbResp.statusCode != 200) return false; + + final dbPath = await DatabaseHelper.instance.databasePath; + if (dbPath != null) { + await DatabaseHelper.instance.close(); + await File(dbPath).writeAsBytes(dbResp.bodyBytes); + await DatabaseHelper.instance.reopen(); + } + + final images = (info['images'] as List?) + ?.map((e) => e is Map ? {'name': e['name'] as String, 'rel_path': e['rel_path'] as String} : null) + .where((e) => e != null).cast>().toList() ?? []; + + final appDir = await getApplicationDocumentsDirectory(); + for (final img in images) { + try { + final relPath = img['rel_path']!; + final imgResp = await http.get( + Uri.parse('$url/api/sync/download/image/$code/$relPath'), + ).timeout(const Duration(seconds: 30)); + if (imgResp.statusCode == 200) { + final dest = File(p.join(appDir.path, relPath)); + await dest.parent.create(recursive: true); + await dest.writeAsBytes(imgResp.bodyBytes); + } + } catch (_) {} + } + + debugPrint('[Sync] 下载到本地完成'); + return true; + } catch (e) { + debugPrint('[Sync] 下载到本地异常: $e'); + return false; + } finally { + _isSyncing = false; + } + } +} diff --git a/lib/utils/usage_stats_service.dart b/lib/utils/usage_stats_service.dart index 6216265..38f83ee 100644 --- a/lib/utils/usage_stats_service.dart +++ b/lib/utils/usage_stats_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io' show Platform; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; @@ -21,7 +22,7 @@ class UsageStatsService with WidgetsBindingObserver { Timer? _heartbeatTimer; bool _started = false; - static const _heartbeatInterval = Duration(minutes: 5); + static const _heartbeatInterval = Duration(minutes: 1); /// 启动统计服务(App 启动时调用一次) Future start() async { @@ -103,7 +104,11 @@ class UsageStatsService with WidgetsBindingObserver { .post( Uri.parse('$serverUrl/api/heartbeat'), headers: {'Content-Type': 'application/json'}, - body: jsonEncode({'device_hash': deviceId}), + body: jsonEncode({ + 'device_hash': deviceId, + 'device_type': Platform.operatingSystem, // android/ios/windows/macos/linux + 'device_name': '${Platform.operatingSystem} ${Platform.operatingSystemVersion}', + }), ) .timeout(const Duration(seconds: 5)); } catch (_) { diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index b1404c8..9928b1b 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -105,4 +105,30 @@ class UserPrefs { /// 匿名设备标识(首次启动自动生成) String get deviceId => prefs.getString('deviceId') ?? ''; Future setDeviceId(String value) => prefs.setString('deviceId', value); + + // ========== 服务端实时同步设置 ========== + + /// 服务器地址 + String get syncServerUrl => prefs.getString('syncServerUrl') ?? ''; + Future setSyncServerUrl(String value) => prefs.setString('syncServerUrl', value); + + /// 激活码 + String get syncActivationCode => prefs.getString('syncActivationCode') ?? ''; + Future setSyncActivationCode(String value) => prefs.setString('syncActivationCode', value); + + /// 激活码有效期 + String get syncExpiresAt => prefs.getString('syncExpiresAt') ?? ''; + Future setSyncExpiresAt(String value) => prefs.setString('syncExpiresAt', value); + + /// 是否永久有效 + bool get syncIsPermanent => prefs.getBool('syncIsPermanent') ?? false; + Future setSyncIsPermanent(bool value) => prefs.setBool('syncIsPermanent', value); + + /// 实时同步开关(默认开启) + bool get syncEnabled => prefs.getBool('syncEnabled') ?? true; + Future setSyncEnabled(bool value) => prefs.setBool('syncEnabled', value); + + /// 上次同步到的 entry id + int get syncLastEntryId => prefs.getInt('syncLastEntryId') ?? 0; + Future setSyncLastEntryId(int value) => prefs.setInt('syncLastEntryId', value); } diff --git a/lib/widgets/fade_in_local_image.dart b/lib/widgets/fade_in_local_image.dart index 92ec3d2..c28a33d 100644 --- a/lib/widgets/fade_in_local_image.dart +++ b/lib/widgets/fade_in_local_image.dart @@ -1,7 +1,9 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../utils/sync/server_data_service.dart'; -/// 带淡入动画的本地图片组件 +/// 带淡入动画的图片组件(支持本地文件 + 服务端 URL 回退) class FadeInLocalImage extends StatefulWidget { final String? path; final double? width; @@ -32,27 +34,54 @@ class _FadeInLocalImageState extends State late Animation _opacity; bool _loaded = false; bool _error = false; + String? _imageUrl; + bool _useNetwork = false; @override void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: widget.duration); _opacity = CurvedAnimation(parent: _controller, curve: Curves.easeIn); - _checkFile(); + _loadImage(); } - void _checkFile() { + Future _loadImage() async { if (widget.path == null || widget.path!.isEmpty) { setState(() => _error = true); return; } - final file = File(widget.path!); - if (!file.existsSync()) { - setState(() => _error = true); + + // 如果是 http 开头,直接当网络图片 + if (widget.path!.startsWith('http')) { + _useNetwork = true; + _imageUrl = widget.path; + setState(() => _loaded = true); + _controller.forward(); return; } - setState(() => _loaded = true); - _controller.forward(); + + // 本地文件存在就直接显示 + final file = File(widget.path!); + if (file.existsSync()) { + setState(() => _loaded = true); + _controller.forward(); + return; + } + + // 本地不存在,尝试服务端 URL + if (ServerDataService.isActive) { + try { + final url = await ServerDataService.toImageUrl(widget.path!); + debugPrint('[Image] 本地不存在,使用服务端: $url'); + _useNetwork = true; + _imageUrl = url; + setState(() => _loaded = true); + _controller.forward(); + return; + } catch (_) {} + } + + setState(() => _error = true); } @override @@ -61,8 +90,10 @@ class _FadeInLocalImageState extends State if (widget.path != oldWidget.path) { _error = false; _loaded = false; + _useNetwork = false; + _imageUrl = null; _controller.reset(); - _checkFile(); + _loadImage(); } } @@ -93,19 +124,36 @@ class _FadeInLocalImageState extends State } return FadeTransition( opacity: _opacity, - child: Image.file( - File(widget.path!), - width: widget.width, - height: widget.height, - fit: widget.fit, - errorBuilder: (_, __, ___) => widget.errorWidget ?? - Container( + child: _useNetwork + ? Image.network( + _imageUrl!, width: widget.width, height: widget.height, - color: const Color(0xFFF5F5F5), - child: const Icon(Icons.broken_image_outlined, size: 24, color: Color(0xFFCCCCCC)), + fit: widget.fit, + errorBuilder: (_, e, __) { + debugPrint('[Image] 网络加载失败: $_imageUrl, 错误: $e'); + return widget.errorWidget ?? + Container( + width: widget.width, + height: widget.height, + color: const Color(0xFFF5F5F5), + child: const Icon(Icons.broken_image_outlined, size: 24, color: Color(0xFFCCCCCC)), + ); + }, + ) + : Image.file( + File(widget.path!), + width: widget.width, + height: widget.height, + fit: widget.fit, + errorBuilder: (_, __, ___) => widget.errorWidget ?? + Container( + width: widget.width, + height: widget.height, + color: const Color(0xFFF5F5F5), + child: const Icon(Icons.broken_image_outlined, size: 24, color: Color(0xFFCCCCCC)), + ), ), - ), ); } } diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart index 5985dc3..2954bdb 100644 --- a/lib/widgets/note_list_item.dart +++ b/lib/widgets/note_list_item.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; import '../utils/toast_util.dart'; +import 'fade_in_local_image.dart'; /// 笔记列表项组件 - 极简主义设计 class NoteListItem extends StatelessWidget { @@ -164,10 +165,10 @@ class _NoteListItemContent extends StatelessWidget { border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), ), clipBehavior: Clip.antiAlias, - child: Image.file( - File(images[i]), + child: FadeInLocalImage( + path: images[i], fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( + errorWidget: Container( color: const Color(0xFFF5F5F5), ), ), diff --git a/server/README.md b/server/README.md index 816046f..4c124ad 100644 --- a/server/README.md +++ b/server/README.md @@ -27,39 +27,3 @@ gunicorn -w 2 -b 0.0.0.0:5000 app:app ``` 或使用 systemd 设为开机自启。 - -## API - -### POST /api/heartbeat -心跳上报,App 启动时和每 5 分钟调用一次。 - -请求体: -```json -{ "device_hash": "设备匿名标识" } -``` - -### GET /api/stats -获取统计数据。 - -响应: -```json -{ "total_users": 10, "online_users": 3 } -``` - -- `total_users`: 历史总设备数 -- `online_users`: 最近 5 分钟内有心跳的设备数 - -## 数据存储 - -SQLite 数据库 `stats.db`,结构: - -```sql -CREATE TABLE devices ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - device_hash TEXT UNIQUE NOT NULL, -- SHA256 哈希后的设备标识 - first_seen TEXT NOT NULL, -- 首次出现时间 - last_seen TEXT NOT NULL -- 最后心跳时间 -); -``` - -所有数据均为匿名,不包含任何设备原始信息。 diff --git a/server/app.py b/server/app.py index acad8a8..ca7a504 100644 --- a/server/app.py +++ b/server/app.py @@ -1,104 +1,29 @@ -""" -MookNote 用户统计服务 -Flask + SQLite,匿名统计设备数和在线数 -""" - -import sqlite3 -import hashlib +"""MookNote 服务端 - 入口""" import os -from datetime import datetime, timezone, timedelta - -from flask import Flask, request, jsonify +from flask import Flask +from config import JWT_SECRET +from database import init_db +from auth import register_auth_routes +from admin_api import register_admin_routes +from sync_api import register_sync_routes +from data_api import register_data_routes +from web_ui import register_web_routes app = Flask(__name__) +app.config["SECRET_KEY"] = JWT_SECRET -DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stats.db") -ONLINE_THRESHOLD_MINUTES = 5 # 超过此时间未心跳视为离线 +# 初始化数据库 +init_db() - -def get_db() -> sqlite3.Connection: - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - - -def init_db(): - with get_db() as conn: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS devices ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - device_hash TEXT UNIQUE NOT NULL, - first_seen TEXT NOT NULL, - last_seen TEXT NOT NULL - ) - """ - ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_last_seen ON devices(last_seen)" - ) - conn.commit() - - -# ─── API ──────────────────────────────────────────────────────────────────── - - -@app.route("/api/heartbeat", methods=["POST"]) -def heartbeat(): - """接收匿名心跳""" - data = request.get_json(silent=True) or {} - device_hash = data.get("device_hash", "").strip() - if not device_hash: - return jsonify({"error": "device_hash is required"}), 400 - - # 只存哈希,不存原始设备信息 - h = hashlib.sha256(device_hash.encode()).hexdigest() - now_iso = datetime.now(timezone.utc).isoformat() - - with get_db() as conn: - row = conn.execute( - "SELECT id FROM devices WHERE device_hash = ?", (h,) - ).fetchone() - - if row: - conn.execute( - "UPDATE devices SET last_seen = ? WHERE device_hash = ?", - (now_iso, h), - ) - else: - conn.execute( - "INSERT INTO devices (device_hash, first_seen, last_seen) VALUES (?, ?, ?)", - (h, now_iso, now_iso), - ) - conn.commit() - - return jsonify({"status": "ok"}) - - -@app.route("/api/stats", methods=["GET"]) -def stats(): - """获取统计:总用户数和当前在线数""" - threshold = ( - datetime.now(timezone.utc) - timedelta(minutes=ONLINE_THRESHOLD_MINUTES) - ).isoformat() - - with get_db() as conn: - total = conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0] - online = conn.execute( - "SELECT COUNT(*) FROM devices WHERE last_seen >= ?", (threshold,) - ).fetchone()[0] - - return jsonify({"total_users": total, "online_users": online}) - - -@app.route("/", methods=["GET"]) -def index(): - return "MookNote Stats Server is running." - - -# ─── MAIN ─────────────────────────────────────────────────────────────────── +# 注册所有路由模块 +register_auth_routes(app) +register_admin_routes(app) +register_sync_routes(app) +register_data_routes(app) +register_web_routes(app) if __name__ == "__main__": - init_db() + from waitress import serve port = int(os.environ.get("PORT", 5000)) - app.run(host="0.0.0.0", port=port, debug=False) + print(f"MookNote 服务端启动于 http://0.0.0.0:{port}") + serve(app, host="0.0.0.0", port=port) diff --git a/server/requirements.txt b/server/requirements.txt index dbcbaf7..706b28c 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1 +1,3 @@ flask==3.1.0 +pyjwt==2.8.0 +waitress==3.0.0 diff --git a/server/stats.db b/server/stats.db index f72dfe5..b0c0983 100644 Binary files a/server/stats.db and b/server/stats.db differ