diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 35f0e6d..1dc05c4 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -64,11 +64,11 @@ class Movie { watchDate: json['watch_date'] != null ? DateTime.parse(json['watch_date']) : null, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) : DateTime.now(), - updatedAt: json['updated_at'] != null - ? DateTime.parse(json['updated_at']) + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']) : DateTime.now(), isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, ); @@ -79,7 +79,7 @@ class Movie { 'id': id, 'title': title, 'poster_path': posterPath, - 'release_date': releaseDate?.toIso8601String(), + 'release_date': releaseDate?.toUtc().toUtc().toIso8601String(), 'directors': jsonEncode(directors), 'writers': jsonEncode(writers), 'actors': jsonEncode(actors), @@ -88,9 +88,9 @@ class Movie { 'summary': summary, 'rating': rating, 'status': status, - 'watch_date': watchDate?.toIso8601String(), - 'created_at': createdAt.toIso8601String(), - 'updated_at': updatedAt.toIso8601String(), + 'watch_date': watchDate?.toUtc().toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), 'is_deleted': isDeleted ? 1 : 0, }; } @@ -217,11 +217,11 @@ class Book { publishDate: json['publish_date'] != null ? DateTime.parse(json['publish_date']) : null, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) : DateTime.now(), - updatedAt: json['updated_at'] != null - ? DateTime.parse(json['updated_at']) + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']) : DateTime.now(), isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, ); @@ -240,9 +240,9 @@ class Book { 'rating': rating, 'status': status, 'isbn': isbn, - 'publish_date': publishDate?.toIso8601String(), - 'created_at': createdAt.toIso8601String(), - 'updated_at': updatedAt.toIso8601String(), + 'publish_date': publishDate?.toUtc().toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), 'is_deleted': isDeleted ? 1 : 0, }; } @@ -323,11 +323,11 @@ class Note { contentType: json['content_type'] ?? 'markdown', tags: Movie.parseStringList(json['tags']), images: Movie.parseStringList(json['images']), - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) : DateTime.now(), - updatedAt: json['updated_at'] != null - ? DateTime.parse(json['updated_at']) + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']) : DateTime.now(), isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, ); @@ -341,8 +341,8 @@ class Note { 'content_type': contentType, 'tags': jsonEncode(tags), 'images': jsonEncode(images), - 'created_at': createdAt.toIso8601String(), - 'updated_at': updatedAt.toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), 'is_deleted': isDeleted ? 1 : 0, }; } @@ -412,11 +412,11 @@ class MovieReview { source: json['source'] ?? '', reviewType: json['review_type'] ?? 1, isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) : DateTime.now(), - updatedAt: json['updated_at'] != null - ? DateTime.parse(json['updated_at']) + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']) : DateTime.now(), ); } @@ -430,8 +430,8 @@ class MovieReview { 'source': source, 'review_type': reviewType, 'is_deleted': isDeleted ? 1 : 0, - 'created_at': createdAt.toIso8601String(), - 'updated_at': updatedAt.toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), }; } @@ -504,7 +504,7 @@ class MoviePoster { 'movie_id': movieId, 'poster_path': posterPath, 'is_deleted': isDeleted ? 1 : 0, - 'created_at': createdAt.toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), }; } @@ -566,8 +566,8 @@ class BookReview { 'source': source, 'review_type': reviewType, 'is_deleted': isDeleted ? 1 : 0, - 'created_at': createdAt.toIso8601String(), - 'updated_at': updatedAt.toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), }; } @@ -653,8 +653,8 @@ class BookExcerpt { 'content': content, 'comment': comment, 'is_deleted': isDeleted ? 1 : 0, - 'created_at': createdAt.toIso8601String(), - 'updated_at': updatedAt.toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), }; } diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index b273473..392d570 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -22,17 +22,6 @@ class BookDetailPage extends StatefulWidget { } class _BookDetailPageState extends State { - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _refreshBookData(); - } - - void _refreshBookData() { - final provider = context.read(); - provider.loadBooks(); - } - @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; diff --git a/lib/pages/sync/server_sync_page.dart b/lib/pages/sync/server_sync_page.dart index eb2f69a..17c25b8 100644 --- a/lib/pages/sync/server_sync_page.dart +++ b/lib/pages/sync/server_sync_page.dart @@ -138,16 +138,18 @@ class _ServerSyncPageState extends State { await provider.loadNotes(); if (mounted) ToastUtil.show(context, '同步已开启,数据已合并'); } else { - // 关闭同步:先合并最新数据,再切换到本地 - if (mounted) ToastUtil.show(context, '正在同步数据到本地...'); - await ServerSyncService.instance.syncWithServer(); + // 关闭同步:从服务器下载数据库和图片到本地 + if (mounted) ToastUtil.show(context, '正在从服务器下载数据...'); + final success = await ServerSyncService.instance.downloadToLocal(); await _prefs.setSyncEnabled(false); setState(() => _syncEnabled = false); final provider = context.read(); await provider.loadMovies(); await provider.loadBooks(); await provider.loadNotes(); - if (mounted) ToastUtil.show(context, '同步已关闭,已切换到本地数据'); + if (mounted) { + ToastUtil.show(context, success ? '数据已下载到本地' : '服务器无备份,保留本地数据'); + } } } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 55a09c3..d2159c3 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -353,58 +353,73 @@ class AppProvider extends ChangeNotifier { } // ========== 影评相关方法 ========== - + /// 获取影视的所有影评 Future> getMovieReviews(String movieId) async { + if (_useRemote) return await ServerDataService.instance.getMovieReviews(movieId); return await _reviewDao.getReviewsByMovieId(movieId); } - + /// 添加影评 Future addMovieReview(MovieReview review) async { - await _reviewDao.insertReview(review); + if (_useRemote) await ServerDataService.instance.saveMovieReview(review); + else await _reviewDao.insertReview(review); } - + /// 更新影评 Future updateMovieReview(MovieReview review) async { - await _reviewDao.updateReview(review); + if (_useRemote) await ServerDataService.instance.saveMovieReview(review); + else await _reviewDao.updateReview(review); } - + /// 删除影评 Future removeMovieReview(String id) async { - await _reviewDao.deleteReview(id); + if (_useRemote) await ServerDataService.instance.deleteMovieReview(id); + else await _reviewDao.deleteReview(id); } - + /// 获取影视的影评数量 Future getMovieReviewCount(String movieId) async { + if (_useRemote) { + final reviews = await ServerDataService.instance.getMovieReviews(movieId); + return reviews.length; + } return await _reviewDao.getReviewCount(movieId); } - + // ========== 海报墙相关方法 ========== - + /// 获取影视的所有海报 Future> getMoviePosters(String movieId) async { + if (_useRemote) return await ServerDataService.instance.getMoviePosters(movieId); return await _posterDao.getPostersByMovieId(movieId); } - + /// 添加海报 Future addMoviePoster(MoviePoster poster) async { - await _posterDao.insertPoster(poster); + if (_useRemote) await ServerDataService.instance.saveMoviePoster(poster); + else await _posterDao.insertPoster(poster); } - + /// 删除海报 Future removeMoviePoster(String id) async { - // 先获取海报信息,以便删除文件 + if (_useRemote) { + await ServerDataService.instance.deleteMoviePoster(id); + return; + } final poster = await _posterDao.getPosterById(id); if (poster != null) { - // 删除海报文件 await ImagePathHelper.instance.deleteFile(poster.posterPath); } - await _posterDao.deletePoster(id); } - + /// 获取影视的海报数量 Future getMoviePosterCount(String movieId) async { + if (_useRemote) { + final posters = await ServerDataService.instance.getMoviePosters(movieId); + return posters.length; + } return await _posterDao.getPosterCount(movieId); } @@ -412,26 +427,34 @@ class AppProvider extends ChangeNotifier { /// 获取书籍的所有书评 Future> getBookReviews(String bookId) async { + if (_useRemote) return await ServerDataService.instance.getBookReviews(bookId); return await _bookReviewDao.getReviewsByBookId(bookId); } /// 添加书评 Future addBookReview(BookReview review) async { - await _bookReviewDao.insertReview(review); + if (_useRemote) await ServerDataService.instance.saveBookReview(review); + else await _bookReviewDao.insertReview(review); } /// 更新书评 Future updateBookReview(BookReview review) async { - await _bookReviewDao.updateReview(review); + if (_useRemote) await ServerDataService.instance.saveBookReview(review); + else await _bookReviewDao.updateReview(review); } /// 删除书评 Future removeBookReview(String id) async { - await _bookReviewDao.deleteReview(id); + if (_useRemote) await ServerDataService.instance.deleteBookReview(id); + else await _bookReviewDao.deleteReview(id); } /// 获取书籍的书评数量 Future getBookReviewCount(String bookId) async { + if (_useRemote) { + final reviews = await ServerDataService.instance.getBookReviews(bookId); + return reviews.length; + } return await _bookReviewDao.getReviewCount(bookId); } @@ -439,26 +462,34 @@ class AppProvider extends ChangeNotifier { /// 获取书籍的所有摘抄 Future> getBookExcerpts(String bookId) async { + if (_useRemote) return await ServerDataService.instance.getBookExcerpts(bookId); return await _bookExcerptDao.getExcerptsByBookId(bookId); } /// 添加摘抄 Future addBookExcerpt(BookExcerpt excerpt) async { - await _bookExcerptDao.insertExcerpt(excerpt); + if (_useRemote) await ServerDataService.instance.saveBookExcerpt(excerpt); + else await _bookExcerptDao.insertExcerpt(excerpt); } /// 更新摘抄 Future updateBookExcerpt(BookExcerpt excerpt) async { - await _bookExcerptDao.updateExcerpt(excerpt); + if (_useRemote) await ServerDataService.instance.saveBookExcerpt(excerpt); + else await _bookExcerptDao.updateExcerpt(excerpt); } /// 删除摘抄 Future removeBookExcerpt(String id) async { - await _bookExcerptDao.deleteExcerpt(id); + if (_useRemote) await ServerDataService.instance.deleteBookExcerpt(id); + else await _bookExcerptDao.deleteExcerpt(id); } /// 获取书籍的摘抄数量 Future getBookExcerptCount(String bookId) async { + if (_useRemote) { + final excerpts = await ServerDataService.instance.getBookExcerpts(bookId); + return excerpts.length; + } return await _bookExcerptDao.getExcerptCount(bookId); } diff --git a/lib/utils/sync/backup_service.dart b/lib/utils/sync/backup_service.dart index 8932146..0012446 100644 --- a/lib/utils/sync/backup_service.dart +++ b/lib/utils/sync/backup_service.dart @@ -27,6 +27,8 @@ class BackupService { final notes = await db.query('notes'); final movieReviews = await db.query('movie_reviews'); final moviePosters = await db.query('movie_posters'); + final bookReviews = await db.query('book_reviews'); + final bookExcerpts = await db.query('book_excerpts'); final tags = await db.query('tags'); // 收集所有图片路径 @@ -100,6 +102,8 @@ class BackupService { 'notes': notes, 'movie_reviews': movieReviews, 'movie_posters': moviePosters, + 'book_reviews': bookReviews, + 'book_excerpts': bookExcerpts, 'tags': tags, }, }; @@ -211,6 +215,8 @@ class BackupService { final notes = await db.query('notes'); final movieReviews = await db.query('movie_reviews'); final moviePosters = await db.query('movie_posters'); + final bookReviews = await db.query('book_reviews'); + final bookExcerpts = await db.query('book_excerpts'); final tags = await db.query('tags'); // 收集所有图片路径 @@ -284,6 +290,8 @@ class BackupService { 'notes': notes, 'movie_reviews': movieReviews, 'movie_posters': moviePosters, + 'book_reviews': bookReviews, + 'book_excerpts': bookExcerpts, 'tags': tags, }, }; @@ -425,6 +433,8 @@ class BackupService { // 清空现有数据 await txn.delete('movie_reviews'); await txn.delete('movie_posters'); + await txn.delete('book_reviews'); + await txn.delete('book_excerpts'); await txn.delete('movies'); await txn.delete('books'); await txn.delete('notes'); @@ -477,6 +487,20 @@ class BackupService { await txn.insert('movie_posters', updatedMap); } } + // 导入书评数据 + if (data.containsKey('book_reviews')) { + final reviews = data['book_reviews'] as List; + for (final review in reviews) { + await txn.insert('book_reviews', _convertToDbMap(review)); + } + } + // 导入书摘数据 + if (data.containsKey('book_excerpts')) { + final excerpts = data['book_excerpts'] as List; + for (final excerpt in excerpts) { + await txn.insert('book_excerpts', _convertToDbMap(excerpt)); + } + } // 导入标签数据 if (data.containsKey('tags')) { @@ -597,6 +621,8 @@ class BackupService { // 清空现有数据 await txn.delete('movie_reviews'); await txn.delete('movie_posters'); + await txn.delete('book_reviews'); + await txn.delete('book_excerpts'); await txn.delete('movies'); await txn.delete('books'); await txn.delete('notes'); @@ -643,6 +669,20 @@ class BackupService { await txn.insert('movie_posters', updatedMap); } } + // 导入书评数据 + if (data.containsKey('book_reviews')) { + final reviews = data['book_reviews'] as List; + for (final review in reviews) { + await txn.insert('book_reviews', _convertToDbMap(review)); + } + } + // 导入书摘数据 + if (data.containsKey('book_excerpts')) { + final excerpts = data['book_excerpts'] as List; + for (final excerpt in excerpts) { + await txn.insert('book_excerpts', _convertToDbMap(excerpt)); + } + } // 导入标签数据 if (data.containsKey('tags')) { @@ -686,6 +726,8 @@ class BackupService { if (data.containsKey('notes')) stats['笔记'] = (data['notes'] as List).length; if (data.containsKey('movie_reviews')) stats['影评'] = (data['movie_reviews'] as List).length; if (data.containsKey('movie_posters')) stats['海报'] = (data['movie_posters'] as List).length; + if (data.containsKey('book_reviews')) stats['书评'] = (data['book_reviews'] as List).length; + if (data.containsKey('book_excerpts')) stats['书摘'] = (data['book_excerpts'] as List).length; if (imageCount > 0) stats['图片'] = imageCount; return ImportResult.success(stats); diff --git a/lib/utils/sync/server_data_service.dart b/lib/utils/sync/server_data_service.dart index 5b51e60..e9b7ae7 100644 --- a/lib/utils/sync/server_data_service.dart +++ b/lib/utils/sync/server_data_service.dart @@ -108,6 +108,102 @@ class ServerDataService { return data != null; } + // ─── 影评 ──────────────────────────────────────────────────── + + Future> getMovieReviews(String movieId) async { + final data = await _post('/api/data/movie_reviews', {'movie_id': movieId}); + if (data == null || data['reviews'] == null) return []; + return (data['reviews'] as List).map((r) => MovieReview.fromJson(r as Map)).toList(); + } + + Future> getAllMovieReviews() async { + final data = await _post('/api/data/movie_reviews'); + if (data == null || data['reviews'] == null) return []; + return (data['reviews'] as List).map((r) => MovieReview.fromJson(r as Map)).toList(); + } + + Future saveMovieReview(MovieReview review) async { + final data = await _post('/api/data/movie_review/save', {'review': review.toJson()}); + return data != null; + } + + Future deleteMovieReview(String id) async { + final data = await _post('/api/data/movie_review/delete', {'id': id}); + return data != null; + } + + // ─── 海报 ──────────────────────────────────────────────────── + + Future> getMoviePosters(String movieId) async { + final data = await _post('/api/data/movie_posters', {'movie_id': movieId}); + if (data == null || data['posters'] == null) return []; + return (data['posters'] as List).map((p) => MoviePoster.fromJson(p as Map)).toList(); + } + + Future> getAllMoviePosters() async { + final data = await _post('/api/data/movie_posters'); + if (data == null || data['posters'] == null) return []; + return (data['posters'] as List).map((p) => MoviePoster.fromJson(p as Map)).toList(); + } + + Future saveMoviePoster(MoviePoster poster) async { + final data = await _post('/api/data/movie_poster/save', {'poster': poster.toJson()}); + return data != null; + } + + Future deleteMoviePoster(String id) async { + final data = await _post('/api/data/movie_poster/delete', {'id': id}); + return data != null; + } + + // ─── 书评 ──────────────────────────────────────────────────── + + Future> getBookReviews(String bookId) async { + final data = await _post('/api/data/book_reviews', {'book_id': bookId}); + if (data == null || data['reviews'] == null) return []; + return (data['reviews'] as List).map((r) => BookReview.fromJson(r as Map)).toList(); + } + + Future> getAllBookReviews() async { + final data = await _post('/api/data/book_reviews'); + if (data == null || data['reviews'] == null) return []; + return (data['reviews'] as List).map((r) => BookReview.fromJson(r as Map)).toList(); + } + + Future saveBookReview(BookReview review) async { + final data = await _post('/api/data/book_review/save', {'review': review.toJson()}); + return data != null; + } + + Future deleteBookReview(String id) async { + final data = await _post('/api/data/book_review/delete', {'id': id}); + return data != null; + } + + // ─── 书摘 ──────────────────────────────────────────────────── + + Future> getBookExcerpts(String bookId) async { + final data = await _post('/api/data/book_excerpts', {'book_id': bookId}); + if (data == null || data['excerpts'] == null) return []; + return (data['excerpts'] as List).map((e) => BookExcerpt.fromJson(e as Map)).toList(); + } + + Future> getAllBookExcerpts() async { + final data = await _post('/api/data/book_excerpts'); + if (data == null || data['excerpts'] == null) return []; + return (data['excerpts'] as List).map((e) => BookExcerpt.fromJson(e as Map)).toList(); + } + + Future saveBookExcerpt(BookExcerpt excerpt) async { + final data = await _post('/api/data/book_excerpt/save', {'excerpt': excerpt.toJson()}); + return data != null; + } + + Future deleteBookExcerpt(String id) async { + final data = await _post('/api/data/book_excerpt/delete', {'id': id}); + return data != null; + } + // ─── 批量同步 ──────────────────────────────────────────────── Future> batchSync({ @@ -115,12 +211,20 @@ class ServerDataService { List? books, List? notes, List>? tags, + List>? movieReviews, + List>? moviePosters, + List>? bookReviews, + List>? bookExcerpts, }) async { final data = await _post('/api/data/batch_sync', { if (movies != null) 'movies': movies.map((m) => m.toJson()).toList(), if (books != null) 'books': books.map((b) => b.toJson()).toList(), if (notes != null) 'notes': notes.map((n) => n.toJson()).toList(), if (tags != null) 'tags': tags, + if (movieReviews != null) 'movie_reviews': movieReviews, + if (moviePosters != null) 'movie_posters': moviePosters, + if (bookReviews != null) 'book_reviews': bookReviews, + if (bookExcerpts != null) 'book_excerpts': bookExcerpts, }); if (data == null) return {}; return { @@ -128,6 +232,10 @@ class ServerDataService { 'books': (data['books'] as int?) ?? 0, 'notes': (data['notes'] as int?) ?? 0, 'tags': (data['tags'] as int?) ?? 0, + 'movie_reviews': (data['movie_reviews'] as int?) ?? 0, + 'movie_posters': (data['movie_posters'] as int?) ?? 0, + 'book_reviews': (data['book_reviews'] as int?) ?? 0, + 'book_excerpts': (data['book_excerpts'] as int?) ?? 0, }; } @@ -168,9 +276,11 @@ class ServerDataService { /// 批量上传图片到服务端(自动计算相对路径) static Future uploadLocalImages(List filePaths) async { + debugPrint('[Sync] uploadLocalImages: isActive=$isActive count=${filePaths.length}'); if (!isActive || filePaths.isEmpty) return; + debugPrint('[Sync] 调用 uploadImages...'); final result = await instance.uploadImages(filePaths); - debugPrint('[Sync] 上传 ${result.length}/${filePaths.length} 张图片'); + debugPrint('[Sync] uploadImages 返回: ${result.length} 个文件'); } /// 上传单张图片到服务端 @@ -185,20 +295,29 @@ class ServerDataService { } Future> uploadImages(List filePaths) async { + debugPrint('[Sync] uploadImages: 准备上传 ${filePaths.length} 个文件到 $_baseUrl/api/data/image/upload'); 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 exists = await file.exists(); + final size = exists ? await file.length() : 0; + debugPrint('[Sync] 图片: $relPath (存在=$exists 大小=$size)'); + if (exists) { + request.files.add(await http.MultipartFile( + 'images', file.readAsBytes().asStream(), size, + filename: relPath, + )); + } } + debugPrint('[Sync] 发送 upload 请求 (${request.files.length} 个文件)...'); final resp = await request.send().timeout(const Duration(seconds: 60)); + debugPrint('[Sync] upload 响应: ${resp.statusCode}'); if (resp.statusCode != 200) return []; final body = await resp.stream.bytesToString(); + debugPrint('[Sync] upload 响应体: $body'); final data = jsonDecode(body) as Map; return (data['files'] as List?)?.cast() ?? []; } diff --git a/lib/utils/sync/server_sync_service.dart b/lib/utils/sync/server_sync_service.dart index c762640..24bff6a 100644 --- a/lib/utils/sync/server_sync_service.dart +++ b/lib/utils/sync/server_sync_service.dart @@ -53,32 +53,54 @@ class ServerSyncService { /// 开启同步:智能合并本地与服务端数据 Future syncWithServer() async { - if (!isConfigured || _isSyncing) return false; + if (!isConfigured || _isSyncing) { + debugPrint('[Sync] 跳过: configured=$isConfigured syncing=$_isSyncing'); + return false; + } _isSyncing = true; try { + debugPrint('[Sync] ========== 开始同步 =========='); final server = ServerDataService.instance; // 读取本地数据 final localMovies = await MovieDao().getAllMovies(); final localBooks = await BookDao().getAllBooks(); final localNotes = await NoteDao().getAllNotes(); + final db = await DatabaseHelper.instance.database; + final localMovieReviews = await db.query('movie_reviews'); + final localMoviePosters = await db.query('movie_posters'); + final localBookReviews = await db.query('book_reviews'); + final localBookExcerpts = await db.query('book_excerpts'); + + debugPrint('[Sync] 本地: 影视${localMovies.length} 书籍${localBooks.length} 笔记${localNotes.length} ' + '影评${localMovieReviews.length} 海报${localMoviePosters.length} ' + '书评${localBookReviews.length} 书摘${localBookExcerpts.length}'); // 读取服务端数据 final remoteMovies = await server.getMovies(); final remoteBooks = await server.getBooks(); final remoteNotes = await server.getNotes(); + debugPrint('[Sync] 服务端: 影视${remoteMovies.length} 书籍${remoteBooks.length} 笔记${remoteNotes.length}'); + // 需要 push 到服务端的数据 final pushMovies = []; final pushBooks = []; final pushNotes = []; - final imagePaths = []; + final pushMovieReviews = >[]; + final pushMoviePosters = >[]; + final pushBookReviews = >[]; + final pushBookExcerpts = >[]; // 服务端无数据 → 全量 push if (remoteMovies.isEmpty && remoteBooks.isEmpty && remoteNotes.isEmpty) { pushMovies.addAll(localMovies); pushBooks.addAll(localBooks); pushNotes.addAll(localNotes); + pushMovieReviews.addAll(localMovieReviews); + pushMoviePosters.addAll(localMoviePosters); + pushBookReviews.addAll(localBookReviews); + pushBookExcerpts.addAll(localBookExcerpts); } else { // 按 updated_at 合并 final remoteMovieMap = {for (final m in remoteMovies) m.id: m}; @@ -119,36 +141,73 @@ class ServerSyncService { for (final r in remoteNoteMap.values) { await _upsertLocalNote(n: r); } + + // 子表合并:movie_reviews / movie_posters / book_reviews / book_excerpts + await _mergeSubTable(db, server, 'movie_reviews', localMovieReviews, pushMovieReviews); + await _mergeSubTable(db, server, 'movie_posters', localMoviePosters, pushMoviePosters); + await _mergeSubTable(db, server, 'book_reviews', localBookReviews, pushBookReviews); + await _mergeSubTable(db, server, 'book_excerpts', localBookExcerpts, pushBookExcerpts); } - // 批量 push 到服务端 - if (pushMovies.isNotEmpty || pushBooks.isNotEmpty || pushNotes.isNotEmpty) { - final db = await DatabaseHelper.instance.database; + // 收集所有本地图片路径 + final allImagePaths = _collectAllLocalImages( + localMovies, localBooks, localNotes, localMoviePosters); + debugPrint('[Sync] 全部图片: ${allImagePaths.length} 张'); + + // 先下载本地缺失的图片 + final missingImages = []; + final existingImages = []; + for (final p in allImagePaths) { + if (await File(p).exists()) { + existingImages.add(p); + } else { + missingImages.add(p); + } + } + debugPrint('[Sync] 缺失${missingImages.length}张 现有${existingImages.length}张'); + + if (missingImages.isNotEmpty) { + debugPrint('[Sync] 下载缺失图片...'); + final appDir = (await getApplicationDocumentsDirectory()).path; + for (final path in missingImages) { + try { + final relPath = p.relative(path, from: appDir).replaceAll('\\', '/'); + final url = '${_prefs.syncServerUrl}/api/data/image/${_prefs.syncActivationCode}/$relPath'; + final resp = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 15)); + if (resp.statusCode == 200) { + final dest = File(path); + await dest.parent.create(recursive: true); + await dest.writeAsBytes(resp.bodyBytes); + } + } catch (_) {} + } + debugPrint('[Sync] 缺失图片下载完成'); + } + + // 上传本地已有图片 + if (existingImages.isNotEmpty) { + debugPrint('[Sync] 上传 ${existingImages.length} 张现有图片...'); + await ServerDataService.uploadLocalImages(existingImages); + } + + // 批量 push 数据到服务端 + final hasPush = pushMovies.isNotEmpty || pushBooks.isNotEmpty || pushNotes.isNotEmpty || + pushMovieReviews.isNotEmpty || pushMoviePosters.isNotEmpty || + pushBookReviews.isNotEmpty || pushBookExcerpts.isNotEmpty; + debugPrint('[Sync] hasPush=$hasPush'); + if (hasPush) { final localTags = await db.query('tags'); final result = await server.batchSync( movies: pushMovies.isEmpty ? null : pushMovies, books: pushBooks.isEmpty ? null : pushBooks, notes: pushNotes.isEmpty ? null : pushNotes, tags: localTags.isEmpty ? null : localTags.cast>(), + movieReviews: pushMovieReviews.isEmpty ? null : pushMovieReviews, + moviePosters: pushMoviePosters.isEmpty ? null : pushMoviePosters, + bookReviews: pushBookReviews.isEmpty ? null : pushBookReviews, + bookExcerpts: pushBookExcerpts.isEmpty ? null : pushBookExcerpts, ); - debugPrint('[Sync] 批量推送: $result'); - - // 收集需要上传的图片 - for (final m in pushMovies) { - if (m.posterPath != null && m.posterPath!.isNotEmpty) imagePaths.add(m.posterPath!); - } - for (final b in pushBooks) { - if (b.coverPath != null && b.coverPath!.isNotEmpty) imagePaths.add(b.coverPath!); - } - for (final n in pushNotes) { - imagePaths.addAll(n.images.where((i) => i.isNotEmpty)); - } - } - - // 上传图片 - if (imagePaths.isNotEmpty) { - debugPrint('[Sync] 上传 ${imagePaths.length} 张图片'); - await ServerDataService.uploadLocalImages(imagePaths); + debugPrint('[Sync] batchSync 结果: $result'); } debugPrint('[Sync] 合并完成'); @@ -178,30 +237,169 @@ class ServerSyncService { await db.insert('notes', n.toJson(), conflictAlgorithm: ConflictAlgorithm.replace); } - /// 关闭同步:从服务器下载数据到本地 - Future downloadToLocal() async { + /// 收集所有实际存在的本地图片路径 + List _collectAllLocalImages(List movies, List books, + List notes, List> posters) { + final paths = []; + for (final m in movies) { + if (m.posterPath != null && m.posterPath!.isNotEmpty) paths.add(m.posterPath!); + } + for (final b in books) { + if (b.coverPath != null && b.coverPath!.isNotEmpty) paths.add(b.coverPath!); + } + for (final n in notes) { + paths.addAll(n.images.where((i) => i.isNotEmpty)); + } + for (final p in posters) { + final pp = p['poster_path'] as String?; + if (pp != null && pp.isNotEmpty) paths.add(pp); + } + return paths; + } + + /// 合并子表(reviews/posters/excerpts):本地优先 push,服务端补充 + Future _mergeSubTable(Database db, ServerDataService server, String table, + List> local, List> pushList) async { + // 尝试获取服务端数据 + List> remote = []; + bool serverOk = false; + try { + switch (table) { + case 'movie_reviews': + remote = (await server.getAllMovieReviews()).map((r) => r.toJson()).toList(); + case 'movie_posters': + remote = (await server.getAllMoviePosters()).map((p) => p.toJson()).toList(); + case 'book_reviews': + remote = (await server.getAllBookReviews()).map((r) => r.toJson()).toList(); + case 'book_excerpts': + remote = (await server.getAllBookExcerpts()).map((e) => e.toJson()).toList(); + } + serverOk = true; + } catch (_) {} + + if (!serverOk) { + // 服务端不可用 → 全量 push 本地数据 + pushList.addAll(local); + return; + } + + final remoteMap = {for (final r in remote) r['id'] as String: r}; + for (final l in local) { + final r = remoteMap.remove(l['id'] as String); + if (r == null) { + pushList.add(l); + } else { + final lTime = l['updated_at'] as String? ?? ''; + final rTime = r['updated_at'] as String? ?? ''; + if (lTime.compareTo(rTime) > 0) pushList.add(l); + } + } + // 服务端有、本地无 → 写入本地 + for (final r in remoteMap.values) { + await db.insert(table, r, conflictAlgorithm: ConflictAlgorithm.replace); + } + } + + /// 关闭同步:上传完整备份到服务器后切回本地 + Future uploadBackupAndDisconnect() async { if (!isConfigured || _isSyncing) return false; _isSyncing = true; try { + debugPrint('[Sync] ========== 关闭同步:上传备份 =========='); + final url = _prefs.syncServerUrl; + final code = _prefs.syncActivationCode; + final deviceId = _prefs.deviceId; + final appDir = (await getApplicationDocumentsDirectory()).path; + + // 上传数据库 + final dbPath = await DatabaseHelper.instance.databasePath; + final request = http.MultipartRequest('POST', Uri.parse('$url/api/sync/upload')); + request.fields['code'] = code; + request.fields['device_id'] = deviceId; + if (dbPath != null && File(dbPath).existsSync()) { + request.files.add(await http.MultipartFile( + 'database', File(dbPath).readAsBytes().asStream(), await File(dbPath).length(), + filename: 'mooknote.db', + )); + debugPrint('[Sync] 上传数据库: ${await File(dbPath).length()} bytes'); + } + + // 收集并上传所有图片 + final imagePaths = _collectAllImageFiles(appDir); + debugPrint('[Sync] 上传 ${imagePaths.length} 张图片...'); + for (final path in imagePaths) { + final relPath = p.relative(path, from: appDir).replaceAll('\\', '/'); + if (await File(path).exists()) { + request.files.add(await http.MultipartFile( + 'images', File(path).readAsBytes().asStream(), await File(path).length(), + filename: relPath, + )); + } + } + + final resp = await request.send().timeout(const Duration(seconds: 300)); + final body = await resp.stream.bytesToString(); + debugPrint('[Sync] 上传备份响应: ${resp.statusCode} $body'); + + if (resp.statusCode == 200) { + debugPrint('[Sync] 备份上传成功,关闭同步'); + } + return resp.statusCode == 200; + } catch (e) { + debugPrint('[Sync] 上传备份异常: $e'); + return false; + } finally { + _isSyncing = false; + } + } + + /// 收集所有图片文件(递归扫描 images 目录) + List _collectAllImageFiles(String appDir) { + final paths = []; + final imagesDir = Directory(p.join(appDir, 'images')); + if (!imagesDir.existsSync()) return paths; + for (final entity in imagesDir.listSync(recursive: true)) { + if (entity is File) { + paths.add(entity.path); + } + } + return paths; + } + Future downloadToLocal() async { + if (!isConfigured || _isSyncing) { + debugPrint('[Sync] downloadToLocal 跳过: configured=$isConfigured syncing=$_isSyncing'); + return false; + } + _isSyncing = true; + try { + debugPrint('[Sync] ========== 关闭同步:从服务器下载 =========='); final url = _prefs.syncServerUrl; final code = _prefs.syncActivationCode; final deviceId = _prefs.deviceId; + debugPrint('[Sync] 查询备份信息...'); 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)); + debugPrint('[Sync] /api/sync/info 响应: ${infoResp.statusCode}'); if (infoResp.statusCode != 200) return false; final info = _jsonDecode(infoResp.body); - if (info == null || info['has_backup'] != true) return false; + debugPrint('[Sync] info: $info'); + if (info == null || info['has_backup'] != true) { + debugPrint('[Sync] 服务器无备份,跳过下载'); + return false; + } + debugPrint('[Sync] 下载数据库...'); 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)); + debugPrint('[Sync] 数据库下载响应: ${dbResp.statusCode} size=${dbResp.bodyBytes.length}'); if (dbResp.statusCode != 200) return false; final dbPath = await DatabaseHelper.instance.databasePath; @@ -209,16 +407,19 @@ class ServerSyncService { await DatabaseHelper.instance.close(); await File(dbPath).writeAsBytes(dbResp.bodyBytes); await DatabaseHelper.instance.reopen(); + debugPrint('[Sync] 数据库已写入: $dbPath'); } 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() ?? []; + debugPrint('[Sync] 下载 ${images.length} 张图片...'); final appDir = await getApplicationDocumentsDirectory(); + int downloaded = 0; for (final img in images) { try { - final relPath = img['rel_path']!; + final relPath = img['rel_path']!.replaceAll('\\', '/'); final imgResp = await http.get( Uri.parse('$url/api/sync/download/image/$code/$relPath'), ).timeout(const Duration(seconds: 30)); @@ -226,9 +427,11 @@ class ServerSyncService { final dest = File(p.join(appDir.path, relPath)); await dest.parent.create(recursive: true); await dest.writeAsBytes(imgResp.bodyBytes); + downloaded++; } } catch (_) {} } + debugPrint('[Sync] 下载完成: 数据库 + $downloaded/${images.length} 张图片'); debugPrint('[Sync] 下载到本地完成'); return true; diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart index 6e2677b..13ecffc 100644 --- a/lib/widgets/note_list_item.dart +++ b/lib/widgets/note_list_item.dart @@ -50,7 +50,7 @@ class _NoteListItemContent extends StatelessWidget { Row( children: [ Text( - _formatDateCached(note.updatedAt), + _formatDate(note.createdAt), style: TextStyle( fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4), @@ -256,24 +256,14 @@ class _NoteListItemContent extends StatelessWidget { } } -final Map _dateFormatCache = {}; - -String _formatDateCached(DateTime date) { - final cacheKey = DateTime(date.year, date.month, date.day); - - if (_dateFormatCache.containsKey(cacheKey)) { - return _dateFormatCache[cacheKey]!; - } - - final result = _formatDate(date); - _dateFormatCache[cacheKey] = result; - return result; -} - String _formatDate(DateTime date) { final now = DateTime.now(); final difference = now.difference(date); + if (difference.isNegative) { + // 服务端时间比本地快(时钟偏差),显示绝对日期 + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } if (difference.inDays == 0) { if (difference.inHours == 0) { if (difference.inMinutes == 0) {