云同步功能完成

This commit is contained in:
DelLevin-Home
2026-05-28 08:06:44 +08:00
parent 21f9c55018
commit 7efd03c6ba
8 changed files with 493 additions and 117 deletions

View File

@@ -79,7 +79,7 @@ class Movie {
'id': id, 'id': id,
'title': title, 'title': title,
'poster_path': posterPath, 'poster_path': posterPath,
'release_date': releaseDate?.toIso8601String(), 'release_date': releaseDate?.toUtc().toUtc().toIso8601String(),
'directors': jsonEncode(directors), 'directors': jsonEncode(directors),
'writers': jsonEncode(writers), 'writers': jsonEncode(writers),
'actors': jsonEncode(actors), 'actors': jsonEncode(actors),
@@ -88,9 +88,9 @@ class Movie {
'summary': summary, 'summary': summary,
'rating': rating, 'rating': rating,
'status': status, 'status': status,
'watch_date': watchDate?.toIso8601String(), 'watch_date': watchDate?.toUtc().toIso8601String(),
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(),
'is_deleted': isDeleted ? 1 : 0, 'is_deleted': isDeleted ? 1 : 0,
}; };
} }
@@ -240,9 +240,9 @@ class Book {
'rating': rating, 'rating': rating,
'status': status, 'status': status,
'isbn': isbn, 'isbn': isbn,
'publish_date': publishDate?.toIso8601String(), 'publish_date': publishDate?.toUtc().toIso8601String(),
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(),
'is_deleted': isDeleted ? 1 : 0, 'is_deleted': isDeleted ? 1 : 0,
}; };
} }
@@ -341,8 +341,8 @@ class Note {
'content_type': contentType, 'content_type': contentType,
'tags': jsonEncode(tags), 'tags': jsonEncode(tags),
'images': jsonEncode(images), 'images': jsonEncode(images),
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(),
'is_deleted': isDeleted ? 1 : 0, 'is_deleted': isDeleted ? 1 : 0,
}; };
} }
@@ -430,8 +430,8 @@ class MovieReview {
'source': source, 'source': source,
'review_type': reviewType, 'review_type': reviewType,
'is_deleted': isDeleted ? 1 : 0, 'is_deleted': isDeleted ? 1 : 0,
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(),
}; };
} }
@@ -504,7 +504,7 @@ class MoviePoster {
'movie_id': movieId, 'movie_id': movieId,
'poster_path': posterPath, 'poster_path': posterPath,
'is_deleted': isDeleted ? 1 : 0, 'is_deleted': isDeleted ? 1 : 0,
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toUtc().toIso8601String(),
}; };
} }
@@ -566,8 +566,8 @@ class BookReview {
'source': source, 'source': source,
'review_type': reviewType, 'review_type': reviewType,
'is_deleted': isDeleted ? 1 : 0, 'is_deleted': isDeleted ? 1 : 0,
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(),
}; };
} }
@@ -653,8 +653,8 @@ class BookExcerpt {
'content': content, 'content': content,
'comment': comment, 'comment': comment,
'is_deleted': isDeleted ? 1 : 0, 'is_deleted': isDeleted ? 1 : 0,
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(),
}; };
} }

View File

@@ -22,17 +22,6 @@ class BookDetailPage extends StatefulWidget {
} }
class _BookDetailPageState extends State<BookDetailPage> { class _BookDetailPageState extends State<BookDetailPage> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
_refreshBookData();
}
void _refreshBookData() {
final provider = context.read<AppProvider>();
provider.loadBooks();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;

View File

@@ -138,16 +138,18 @@ class _ServerSyncPageState extends State<ServerSyncPage> {
await provider.loadNotes(); await provider.loadNotes();
if (mounted) ToastUtil.show(context, '同步已开启,数据已合并'); if (mounted) ToastUtil.show(context, '同步已开启,数据已合并');
} else { } else {
// 关闭同步:先合并最新数据,再切换到本地 // 关闭同步:从服务器下载数据库和图片到本地
if (mounted) ToastUtil.show(context, '正在同步数据到本地...'); if (mounted) ToastUtil.show(context, '正在从服务器下载数据...');
await ServerSyncService.instance.syncWithServer(); final success = await ServerSyncService.instance.downloadToLocal();
await _prefs.setSyncEnabled(false); await _prefs.setSyncEnabled(false);
setState(() => _syncEnabled = false); setState(() => _syncEnabled = false);
final provider = context.read<AppProvider>(); final provider = context.read<AppProvider>();
await provider.loadMovies(); await provider.loadMovies();
await provider.loadBooks(); await provider.loadBooks();
await provider.loadNotes(); await provider.loadNotes();
if (mounted) ToastUtil.show(context, '同步已关闭,已切换到本地数据'); if (mounted) {
ToastUtil.show(context, success ? '数据已下载到本地' : '服务器无备份,保留本地数据');
}
} }
} }

View File

@@ -356,26 +356,34 @@ class AppProvider extends ChangeNotifier {
/// 获取影视的所有影评 /// 获取影视的所有影评
Future<List<MovieReview>> getMovieReviews(String movieId) async { Future<List<MovieReview>> getMovieReviews(String movieId) async {
if (_useRemote) return await ServerDataService.instance.getMovieReviews(movieId);
return await _reviewDao.getReviewsByMovieId(movieId); return await _reviewDao.getReviewsByMovieId(movieId);
} }
/// 添加影评 /// 添加影评
Future<void> addMovieReview(MovieReview review) async { Future<void> addMovieReview(MovieReview review) async {
await _reviewDao.insertReview(review); if (_useRemote) await ServerDataService.instance.saveMovieReview(review);
else await _reviewDao.insertReview(review);
} }
/// 更新影评 /// 更新影评
Future<void> updateMovieReview(MovieReview review) async { Future<void> updateMovieReview(MovieReview review) async {
await _reviewDao.updateReview(review); if (_useRemote) await ServerDataService.instance.saveMovieReview(review);
else await _reviewDao.updateReview(review);
} }
/// 删除影评 /// 删除影评
Future<void> removeMovieReview(String id) async { Future<void> removeMovieReview(String id) async {
await _reviewDao.deleteReview(id); if (_useRemote) await ServerDataService.instance.deleteMovieReview(id);
else await _reviewDao.deleteReview(id);
} }
/// 获取影视的影评数量 /// 获取影视的影评数量
Future<int> getMovieReviewCount(String movieId) async { Future<int> getMovieReviewCount(String movieId) async {
if (_useRemote) {
final reviews = await ServerDataService.instance.getMovieReviews(movieId);
return reviews.length;
}
return await _reviewDao.getReviewCount(movieId); return await _reviewDao.getReviewCount(movieId);
} }
@@ -383,28 +391,35 @@ class AppProvider extends ChangeNotifier {
/// 获取影视的所有海报 /// 获取影视的所有海报
Future<List<MoviePoster>> getMoviePosters(String movieId) async { Future<List<MoviePoster>> getMoviePosters(String movieId) async {
if (_useRemote) return await ServerDataService.instance.getMoviePosters(movieId);
return await _posterDao.getPostersByMovieId(movieId); return await _posterDao.getPostersByMovieId(movieId);
} }
/// 添加海报 /// 添加海报
Future<void> addMoviePoster(MoviePoster poster) async { Future<void> addMoviePoster(MoviePoster poster) async {
await _posterDao.insertPoster(poster); if (_useRemote) await ServerDataService.instance.saveMoviePoster(poster);
else await _posterDao.insertPoster(poster);
} }
/// 删除海报 /// 删除海报
Future<void> removeMoviePoster(String id) async { Future<void> removeMoviePoster(String id) async {
// 先获取海报信息,以便删除文件 if (_useRemote) {
await ServerDataService.instance.deleteMoviePoster(id);
return;
}
final poster = await _posterDao.getPosterById(id); final poster = await _posterDao.getPosterById(id);
if (poster != null) { if (poster != null) {
// 删除海报文件
await ImagePathHelper.instance.deleteFile(poster.posterPath); await ImagePathHelper.instance.deleteFile(poster.posterPath);
} }
await _posterDao.deletePoster(id); await _posterDao.deletePoster(id);
} }
/// 获取影视的海报数量 /// 获取影视的海报数量
Future<int> getMoviePosterCount(String movieId) async { Future<int> getMoviePosterCount(String movieId) async {
if (_useRemote) {
final posters = await ServerDataService.instance.getMoviePosters(movieId);
return posters.length;
}
return await _posterDao.getPosterCount(movieId); return await _posterDao.getPosterCount(movieId);
} }
@@ -412,26 +427,34 @@ class AppProvider extends ChangeNotifier {
/// 获取书籍的所有书评 /// 获取书籍的所有书评
Future<List<BookReview>> getBookReviews(String bookId) async { Future<List<BookReview>> getBookReviews(String bookId) async {
if (_useRemote) return await ServerDataService.instance.getBookReviews(bookId);
return await _bookReviewDao.getReviewsByBookId(bookId); return await _bookReviewDao.getReviewsByBookId(bookId);
} }
/// 添加书评 /// 添加书评
Future<void> addBookReview(BookReview review) async { Future<void> addBookReview(BookReview review) async {
await _bookReviewDao.insertReview(review); if (_useRemote) await ServerDataService.instance.saveBookReview(review);
else await _bookReviewDao.insertReview(review);
} }
/// 更新书评 /// 更新书评
Future<void> updateBookReview(BookReview review) async { Future<void> updateBookReview(BookReview review) async {
await _bookReviewDao.updateReview(review); if (_useRemote) await ServerDataService.instance.saveBookReview(review);
else await _bookReviewDao.updateReview(review);
} }
/// 删除书评 /// 删除书评
Future<void> removeBookReview(String id) async { Future<void> removeBookReview(String id) async {
await _bookReviewDao.deleteReview(id); if (_useRemote) await ServerDataService.instance.deleteBookReview(id);
else await _bookReviewDao.deleteReview(id);
} }
/// 获取书籍的书评数量 /// 获取书籍的书评数量
Future<int> getBookReviewCount(String bookId) async { Future<int> getBookReviewCount(String bookId) async {
if (_useRemote) {
final reviews = await ServerDataService.instance.getBookReviews(bookId);
return reviews.length;
}
return await _bookReviewDao.getReviewCount(bookId); return await _bookReviewDao.getReviewCount(bookId);
} }
@@ -439,26 +462,34 @@ class AppProvider extends ChangeNotifier {
/// 获取书籍的所有摘抄 /// 获取书籍的所有摘抄
Future<List<BookExcerpt>> getBookExcerpts(String bookId) async { Future<List<BookExcerpt>> getBookExcerpts(String bookId) async {
if (_useRemote) return await ServerDataService.instance.getBookExcerpts(bookId);
return await _bookExcerptDao.getExcerptsByBookId(bookId); return await _bookExcerptDao.getExcerptsByBookId(bookId);
} }
/// 添加摘抄 /// 添加摘抄
Future<void> addBookExcerpt(BookExcerpt excerpt) async { Future<void> addBookExcerpt(BookExcerpt excerpt) async {
await _bookExcerptDao.insertExcerpt(excerpt); if (_useRemote) await ServerDataService.instance.saveBookExcerpt(excerpt);
else await _bookExcerptDao.insertExcerpt(excerpt);
} }
/// 更新摘抄 /// 更新摘抄
Future<void> updateBookExcerpt(BookExcerpt excerpt) async { Future<void> updateBookExcerpt(BookExcerpt excerpt) async {
await _bookExcerptDao.updateExcerpt(excerpt); if (_useRemote) await ServerDataService.instance.saveBookExcerpt(excerpt);
else await _bookExcerptDao.updateExcerpt(excerpt);
} }
/// 删除摘抄 /// 删除摘抄
Future<void> removeBookExcerpt(String id) async { Future<void> removeBookExcerpt(String id) async {
await _bookExcerptDao.deleteExcerpt(id); if (_useRemote) await ServerDataService.instance.deleteBookExcerpt(id);
else await _bookExcerptDao.deleteExcerpt(id);
} }
/// 获取书籍的摘抄数量 /// 获取书籍的摘抄数量
Future<int> getBookExcerptCount(String bookId) async { Future<int> getBookExcerptCount(String bookId) async {
if (_useRemote) {
final excerpts = await ServerDataService.instance.getBookExcerpts(bookId);
return excerpts.length;
}
return await _bookExcerptDao.getExcerptCount(bookId); return await _bookExcerptDao.getExcerptCount(bookId);
} }

View File

@@ -27,6 +27,8 @@ class BackupService {
final notes = await db.query('notes'); final notes = await db.query('notes');
final movieReviews = await db.query('movie_reviews'); final movieReviews = await db.query('movie_reviews');
final moviePosters = await db.query('movie_posters'); 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'); final tags = await db.query('tags');
// 收集所有图片路径 // 收集所有图片路径
@@ -100,6 +102,8 @@ class BackupService {
'notes': notes, 'notes': notes,
'movie_reviews': movieReviews, 'movie_reviews': movieReviews,
'movie_posters': moviePosters, 'movie_posters': moviePosters,
'book_reviews': bookReviews,
'book_excerpts': bookExcerpts,
'tags': tags, 'tags': tags,
}, },
}; };
@@ -211,6 +215,8 @@ class BackupService {
final notes = await db.query('notes'); final notes = await db.query('notes');
final movieReviews = await db.query('movie_reviews'); final movieReviews = await db.query('movie_reviews');
final moviePosters = await db.query('movie_posters'); 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'); final tags = await db.query('tags');
// 收集所有图片路径 // 收集所有图片路径
@@ -284,6 +290,8 @@ class BackupService {
'notes': notes, 'notes': notes,
'movie_reviews': movieReviews, 'movie_reviews': movieReviews,
'movie_posters': moviePosters, 'movie_posters': moviePosters,
'book_reviews': bookReviews,
'book_excerpts': bookExcerpts,
'tags': tags, 'tags': tags,
}, },
}; };
@@ -425,6 +433,8 @@ class BackupService {
// 清空现有数据 // 清空现有数据
await txn.delete('movie_reviews'); await txn.delete('movie_reviews');
await txn.delete('movie_posters'); await txn.delete('movie_posters');
await txn.delete('book_reviews');
await txn.delete('book_excerpts');
await txn.delete('movies'); await txn.delete('movies');
await txn.delete('books'); await txn.delete('books');
await txn.delete('notes'); await txn.delete('notes');
@@ -477,6 +487,20 @@ class BackupService {
await txn.insert('movie_posters', updatedMap); await txn.insert('movie_posters', updatedMap);
} }
} }
// 导入书评数据
if (data.containsKey('book_reviews')) {
final reviews = data['book_reviews'] as List<dynamic>;
for (final review in reviews) {
await txn.insert('book_reviews', _convertToDbMap(review));
}
}
// 导入书摘数据
if (data.containsKey('book_excerpts')) {
final excerpts = data['book_excerpts'] as List<dynamic>;
for (final excerpt in excerpts) {
await txn.insert('book_excerpts', _convertToDbMap(excerpt));
}
}
// 导入标签数据 // 导入标签数据
if (data.containsKey('tags')) { if (data.containsKey('tags')) {
@@ -597,6 +621,8 @@ class BackupService {
// 清空现有数据 // 清空现有数据
await txn.delete('movie_reviews'); await txn.delete('movie_reviews');
await txn.delete('movie_posters'); await txn.delete('movie_posters');
await txn.delete('book_reviews');
await txn.delete('book_excerpts');
await txn.delete('movies'); await txn.delete('movies');
await txn.delete('books'); await txn.delete('books');
await txn.delete('notes'); await txn.delete('notes');
@@ -643,6 +669,20 @@ class BackupService {
await txn.insert('movie_posters', updatedMap); await txn.insert('movie_posters', updatedMap);
} }
} }
// 导入书评数据
if (data.containsKey('book_reviews')) {
final reviews = data['book_reviews'] as List<dynamic>;
for (final review in reviews) {
await txn.insert('book_reviews', _convertToDbMap(review));
}
}
// 导入书摘数据
if (data.containsKey('book_excerpts')) {
final excerpts = data['book_excerpts'] as List<dynamic>;
for (final excerpt in excerpts) {
await txn.insert('book_excerpts', _convertToDbMap(excerpt));
}
}
// 导入标签数据 // 导入标签数据
if (data.containsKey('tags')) { if (data.containsKey('tags')) {
@@ -686,6 +726,8 @@ class BackupService {
if (data.containsKey('notes')) stats['笔记'] = (data['notes'] as List).length; 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_reviews')) stats['影评'] = (data['movie_reviews'] as List).length;
if (data.containsKey('movie_posters')) stats['海报'] = (data['movie_posters'] 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; if (imageCount > 0) stats['图片'] = imageCount;
return ImportResult.success(stats); return ImportResult.success(stats);

View File

@@ -108,6 +108,102 @@ class ServerDataService {
return data != null; return data != null;
} }
// ─── 影评 ────────────────────────────────────────────────────
Future<List<MovieReview>> 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<String, dynamic>)).toList();
}
Future<List<MovieReview>> 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<String, dynamic>)).toList();
}
Future<bool> saveMovieReview(MovieReview review) async {
final data = await _post('/api/data/movie_review/save', {'review': review.toJson()});
return data != null;
}
Future<bool> deleteMovieReview(String id) async {
final data = await _post('/api/data/movie_review/delete', {'id': id});
return data != null;
}
// ─── 海报 ────────────────────────────────────────────────────
Future<List<MoviePoster>> 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<String, dynamic>)).toList();
}
Future<List<MoviePoster>> 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<String, dynamic>)).toList();
}
Future<bool> saveMoviePoster(MoviePoster poster) async {
final data = await _post('/api/data/movie_poster/save', {'poster': poster.toJson()});
return data != null;
}
Future<bool> deleteMoviePoster(String id) async {
final data = await _post('/api/data/movie_poster/delete', {'id': id});
return data != null;
}
// ─── 书评 ────────────────────────────────────────────────────
Future<List<BookReview>> 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<String, dynamic>)).toList();
}
Future<List<BookReview>> 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<String, dynamic>)).toList();
}
Future<bool> saveBookReview(BookReview review) async {
final data = await _post('/api/data/book_review/save', {'review': review.toJson()});
return data != null;
}
Future<bool> deleteBookReview(String id) async {
final data = await _post('/api/data/book_review/delete', {'id': id});
return data != null;
}
// ─── 书摘 ────────────────────────────────────────────────────
Future<List<BookExcerpt>> 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<String, dynamic>)).toList();
}
Future<List<BookExcerpt>> 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<String, dynamic>)).toList();
}
Future<bool> saveBookExcerpt(BookExcerpt excerpt) async {
final data = await _post('/api/data/book_excerpt/save', {'excerpt': excerpt.toJson()});
return data != null;
}
Future<bool> deleteBookExcerpt(String id) async {
final data = await _post('/api/data/book_excerpt/delete', {'id': id});
return data != null;
}
// ─── 批量同步 ──────────────────────────────────────────────── // ─── 批量同步 ────────────────────────────────────────────────
Future<Map<String, int>> batchSync({ Future<Map<String, int>> batchSync({
@@ -115,12 +211,20 @@ class ServerDataService {
List<Book>? books, List<Book>? books,
List<Note>? notes, List<Note>? notes,
List<Map<String, dynamic>>? tags, List<Map<String, dynamic>>? tags,
List<Map<String, dynamic>>? movieReviews,
List<Map<String, dynamic>>? moviePosters,
List<Map<String, dynamic>>? bookReviews,
List<Map<String, dynamic>>? bookExcerpts,
}) async { }) async {
final data = await _post('/api/data/batch_sync', { final data = await _post('/api/data/batch_sync', {
if (movies != null) 'movies': movies.map((m) => m.toJson()).toList(), if (movies != null) 'movies': movies.map((m) => m.toJson()).toList(),
if (books != null) 'books': books.map((b) => b.toJson()).toList(), if (books != null) 'books': books.map((b) => b.toJson()).toList(),
if (notes != null) 'notes': notes.map((n) => n.toJson()).toList(), if (notes != null) 'notes': notes.map((n) => n.toJson()).toList(),
if (tags != null) 'tags': tags, 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 {}; if (data == null) return {};
return { return {
@@ -128,6 +232,10 @@ class ServerDataService {
'books': (data['books'] as int?) ?? 0, 'books': (data['books'] as int?) ?? 0,
'notes': (data['notes'] as int?) ?? 0, 'notes': (data['notes'] as int?) ?? 0,
'tags': (data['tags'] 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<void> uploadLocalImages(List<String> filePaths) async { static Future<void> uploadLocalImages(List<String> filePaths) async {
debugPrint('[Sync] uploadLocalImages: isActive=$isActive count=${filePaths.length}');
if (!isActive || filePaths.isEmpty) return; if (!isActive || filePaths.isEmpty) return;
debugPrint('[Sync] 调用 uploadImages...');
final result = await instance.uploadImages(filePaths); final result = await instance.uploadImages(filePaths);
debugPrint('[Sync] 上传 ${result.length}/${filePaths.length} 张图片'); debugPrint('[Sync] uploadImages 返回: ${result.length} 个文件');
} }
/// 上传单张图片到服务端 /// 上传单张图片到服务端
@@ -185,20 +295,29 @@ class ServerDataService {
} }
Future<List<String>> uploadImages(List<String> filePaths) async { Future<List<String>> uploadImages(List<String> filePaths) async {
debugPrint('[Sync] uploadImages: 准备上传 ${filePaths.length} 个文件到 $_baseUrl/api/data/image/upload');
final request = http.MultipartRequest('POST', Uri.parse('$_baseUrl/api/data/image/upload')); final request = http.MultipartRequest('POST', Uri.parse('$_baseUrl/api/data/image/upload'));
request.fields['code'] = _code; request.fields['code'] = _code;
final appDir = (await getApplicationDocumentsDirectory()).path; final appDir = (await getApplicationDocumentsDirectory()).path;
for (final path in filePaths) { for (final path in filePaths) {
final relPath = p.relative(path, from: appDir).replaceAll('\\', '/'); final relPath = p.relative(path, from: appDir).replaceAll('\\', '/');
final file = File(path); final file = File(path);
request.files.add(await http.MultipartFile( final exists = await file.exists();
'images', file.readAsBytes().asStream(), await file.length(), final size = exists ? await file.length() : 0;
filename: relPath, 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)); final resp = await request.send().timeout(const Duration(seconds: 60));
debugPrint('[Sync] upload 响应: ${resp.statusCode}');
if (resp.statusCode != 200) return []; if (resp.statusCode != 200) return [];
final body = await resp.stream.bytesToString(); final body = await resp.stream.bytesToString();
debugPrint('[Sync] upload 响应体: $body');
final data = jsonDecode(body) as Map<String, dynamic>; final data = jsonDecode(body) as Map<String, dynamic>;
return (data['files'] as List?)?.cast<String>() ?? []; return (data['files'] as List?)?.cast<String>() ?? [];
} }

View File

@@ -53,32 +53,54 @@ class ServerSyncService {
/// 开启同步:智能合并本地与服务端数据 /// 开启同步:智能合并本地与服务端数据
Future<bool> syncWithServer() async { Future<bool> syncWithServer() async {
if (!isConfigured || _isSyncing) return false; if (!isConfigured || _isSyncing) {
debugPrint('[Sync] 跳过: configured=$isConfigured syncing=$_isSyncing');
return false;
}
_isSyncing = true; _isSyncing = true;
try { try {
debugPrint('[Sync] ========== 开始同步 ==========');
final server = ServerDataService.instance; final server = ServerDataService.instance;
// 读取本地数据 // 读取本地数据
final localMovies = await MovieDao().getAllMovies(); final localMovies = await MovieDao().getAllMovies();
final localBooks = await BookDao().getAllBooks(); final localBooks = await BookDao().getAllBooks();
final localNotes = await NoteDao().getAllNotes(); 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 remoteMovies = await server.getMovies();
final remoteBooks = await server.getBooks(); final remoteBooks = await server.getBooks();
final remoteNotes = await server.getNotes(); final remoteNotes = await server.getNotes();
debugPrint('[Sync] 服务端: 影视${remoteMovies.length} 书籍${remoteBooks.length} 笔记${remoteNotes.length}');
// 需要 push 到服务端的数据 // 需要 push 到服务端的数据
final pushMovies = <Movie>[]; final pushMovies = <Movie>[];
final pushBooks = <Book>[]; final pushBooks = <Book>[];
final pushNotes = <Note>[]; final pushNotes = <Note>[];
final imagePaths = <String>[]; final pushMovieReviews = <Map<String, dynamic>>[];
final pushMoviePosters = <Map<String, dynamic>>[];
final pushBookReviews = <Map<String, dynamic>>[];
final pushBookExcerpts = <Map<String, dynamic>>[];
// 服务端无数据 → 全量 push // 服务端无数据 → 全量 push
if (remoteMovies.isEmpty && remoteBooks.isEmpty && remoteNotes.isEmpty) { if (remoteMovies.isEmpty && remoteBooks.isEmpty && remoteNotes.isEmpty) {
pushMovies.addAll(localMovies); pushMovies.addAll(localMovies);
pushBooks.addAll(localBooks); pushBooks.addAll(localBooks);
pushNotes.addAll(localNotes); pushNotes.addAll(localNotes);
pushMovieReviews.addAll(localMovieReviews);
pushMoviePosters.addAll(localMoviePosters);
pushBookReviews.addAll(localBookReviews);
pushBookExcerpts.addAll(localBookExcerpts);
} else { } else {
// 按 updated_at 合并 // 按 updated_at 合并
final remoteMovieMap = {for (final m in remoteMovies) m.id: m}; final remoteMovieMap = {for (final m in remoteMovies) m.id: m};
@@ -119,36 +141,73 @@ class ServerSyncService {
for (final r in remoteNoteMap.values) { for (final r in remoteNoteMap.values) {
await _upsertLocalNote(n: r); 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 allImagePaths = _collectAllLocalImages(
final db = await DatabaseHelper.instance.database; localMovies, localBooks, localNotes, localMoviePosters);
debugPrint('[Sync] 全部图片: ${allImagePaths.length}');
// 先下载本地缺失的图片
final missingImages = <String>[];
final existingImages = <String>[];
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 localTags = await db.query('tags');
final result = await server.batchSync( final result = await server.batchSync(
movies: pushMovies.isEmpty ? null : pushMovies, movies: pushMovies.isEmpty ? null : pushMovies,
books: pushBooks.isEmpty ? null : pushBooks, books: pushBooks.isEmpty ? null : pushBooks,
notes: pushNotes.isEmpty ? null : pushNotes, notes: pushNotes.isEmpty ? null : pushNotes,
tags: localTags.isEmpty ? null : localTags.cast<Map<String, dynamic>>(), tags: localTags.isEmpty ? null : localTags.cast<Map<String, dynamic>>(),
movieReviews: pushMovieReviews.isEmpty ? null : pushMovieReviews,
moviePosters: pushMoviePosters.isEmpty ? null : pushMoviePosters,
bookReviews: pushBookReviews.isEmpty ? null : pushBookReviews,
bookExcerpts: pushBookExcerpts.isEmpty ? null : pushBookExcerpts,
); );
debugPrint('[Sync] 批量推送: $result'); debugPrint('[Sync] batchSync 结果: $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] 合并完成'); debugPrint('[Sync] 合并完成');
@@ -178,30 +237,169 @@ class ServerSyncService {
await db.insert('notes', n.toJson(), conflictAlgorithm: ConflictAlgorithm.replace); await db.insert('notes', n.toJson(), conflictAlgorithm: ConflictAlgorithm.replace);
} }
/// 关闭同步:从服务器下载数据到本地 /// 收集所有实际存在的本地图片路径
Future<bool> downloadToLocal() async { List<String> _collectAllLocalImages(List<Movie> movies, List<Book> books,
List<Note> notes, List<Map<String, dynamic>> posters) {
final paths = <String>[];
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<void> _mergeSubTable(Database db, ServerDataService server, String table,
List<Map<String, dynamic>> local, List<Map<String, dynamic>> pushList) async {
// 尝试获取服务端数据
List<Map<String, dynamic>> 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<bool> uploadBackupAndDisconnect() async {
if (!isConfigured || _isSyncing) return false; if (!isConfigured || _isSyncing) return false;
_isSyncing = true; _isSyncing = true;
try { 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<String> _collectAllImageFiles(String appDir) {
final paths = <String>[];
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<bool> downloadToLocal() async {
if (!isConfigured || _isSyncing) {
debugPrint('[Sync] downloadToLocal 跳过: configured=$isConfigured syncing=$_isSyncing');
return false;
}
_isSyncing = true;
try {
debugPrint('[Sync] ========== 关闭同步:从服务器下载 ==========');
final url = _prefs.syncServerUrl; final url = _prefs.syncServerUrl;
final code = _prefs.syncActivationCode; final code = _prefs.syncActivationCode;
final deviceId = _prefs.deviceId; final deviceId = _prefs.deviceId;
debugPrint('[Sync] 查询备份信息...');
final infoResp = await http.post( final infoResp = await http.post(
Uri.parse('$url/api/sync/info'), Uri.parse('$url/api/sync/info'),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: '{"code":"$code","device_id":"$deviceId"}', body: '{"code":"$code","device_id":"$deviceId"}',
).timeout(const Duration(seconds: 15)); ).timeout(const Duration(seconds: 15));
debugPrint('[Sync] /api/sync/info 响应: ${infoResp.statusCode}');
if (infoResp.statusCode != 200) return false; if (infoResp.statusCode != 200) return false;
final info = _jsonDecode(infoResp.body); 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( final dbResp = await http.post(
Uri.parse('$url/api/sync/download/database'), Uri.parse('$url/api/sync/download/database'),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: '{"code":"$code"}', body: '{"code":"$code"}',
).timeout(const Duration(seconds: 120)); ).timeout(const Duration(seconds: 120));
debugPrint('[Sync] 数据库下载响应: ${dbResp.statusCode} size=${dbResp.bodyBytes.length}');
if (dbResp.statusCode != 200) return false; if (dbResp.statusCode != 200) return false;
final dbPath = await DatabaseHelper.instance.databasePath; final dbPath = await DatabaseHelper.instance.databasePath;
@@ -209,16 +407,19 @@ class ServerSyncService {
await DatabaseHelper.instance.close(); await DatabaseHelper.instance.close();
await File(dbPath).writeAsBytes(dbResp.bodyBytes); await File(dbPath).writeAsBytes(dbResp.bodyBytes);
await DatabaseHelper.instance.reopen(); await DatabaseHelper.instance.reopen();
debugPrint('[Sync] 数据库已写入: $dbPath');
} }
final images = (info['images'] as List<dynamic>?) final images = (info['images'] as List<dynamic>?)
?.map((e) => e is Map ? {'name': e['name'] as String, 'rel_path': e['rel_path'] as String} : null) ?.map((e) => e is Map ? {'name': e['name'] as String, 'rel_path': e['rel_path'] as String} : null)
.where((e) => e != null).cast<Map<String, String>>().toList() ?? []; .where((e) => e != null).cast<Map<String, String>>().toList() ?? [];
debugPrint('[Sync] 下载 ${images.length} 张图片...');
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
int downloaded = 0;
for (final img in images) { for (final img in images) {
try { try {
final relPath = img['rel_path']!; final relPath = img['rel_path']!.replaceAll('\\', '/');
final imgResp = await http.get( final imgResp = await http.get(
Uri.parse('$url/api/sync/download/image/$code/$relPath'), Uri.parse('$url/api/sync/download/image/$code/$relPath'),
).timeout(const Duration(seconds: 30)); ).timeout(const Duration(seconds: 30));
@@ -226,9 +427,11 @@ class ServerSyncService {
final dest = File(p.join(appDir.path, relPath)); final dest = File(p.join(appDir.path, relPath));
await dest.parent.create(recursive: true); await dest.parent.create(recursive: true);
await dest.writeAsBytes(imgResp.bodyBytes); await dest.writeAsBytes(imgResp.bodyBytes);
downloaded++;
} }
} catch (_) {} } catch (_) {}
} }
debugPrint('[Sync] 下载完成: 数据库 + $downloaded/${images.length} 张图片');
debugPrint('[Sync] 下载到本地完成'); debugPrint('[Sync] 下载到本地完成');
return true; return true;

View File

@@ -50,7 +50,7 @@ class _NoteListItemContent extends StatelessWidget {
Row( Row(
children: [ children: [
Text( Text(
_formatDateCached(note.updatedAt), _formatDate(note.createdAt),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4), color: colors.onSurface.withValues(alpha: 0.4),
@@ -256,24 +256,14 @@ class _NoteListItemContent extends StatelessWidget {
} }
} }
final Map<DateTime, String> _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) { String _formatDate(DateTime date) {
final now = DateTime.now(); final now = DateTime.now();
final difference = now.difference(date); 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.inDays == 0) {
if (difference.inHours == 0) { if (difference.inHours == 0) {
if (difference.inMinutes == 0) { if (difference.inMinutes == 0) {