From df8335b18746a91f4221846718c6c9addbcb88e0 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sat, 8 Aug 2026 22:33:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=96=B0=E5=A2=9E=E4=BA=BA?= =?UTF-8?q?=E7=89=A9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/database_helper.dart | 74 +- lib/data/person/book_person_dao.dart | 87 +++ lib/data/person/game_person_dao.dart | 87 +++ lib/data/person/movie_person_dao.dart | 110 +++ lib/data/person/person_dao.dart | 313 ++++++++ lib/models/data_models.dart | 222 ++++++ lib/pages/book/book_detail_page.dart | 7 +- lib/pages/game/game_detail_page.dart | 6 + lib/pages/movies/movie_detail_page.dart | 12 +- lib/pages/people/person_detail_page.dart | 500 +++++++++++++ lib/pages/people/person_form_page.dart | 602 +++++++++++++++ lib/pages/people/person_list_page.dart | 375 ++++++++++ lib/pages/profile/feature_settings_page.dart | 2 +- lib/pages/settings/recycle_bin_page.dart | 18 +- lib/providers/app_provider.dart | 290 +++++++- lib/services/sync/backup_service.dart | 28 + lib/utils/app_router.dart | 6 + lib/utils/image_path_helper.dart | 24 + lib/widgets/custom_drawer.dart | 4 +- lib/widgets/person_info_sheet.dart | 434 +++++++++++ lib/widgets/work_people_section.dart | 269 +++++++ lib/widgets/work_selector_page.dart | 737 +++++++++++++++++++ 22 files changed, 4196 insertions(+), 11 deletions(-) create mode 100644 lib/data/person/book_person_dao.dart create mode 100644 lib/data/person/game_person_dao.dart create mode 100644 lib/data/person/movie_person_dao.dart create mode 100644 lib/data/person/person_dao.dart create mode 100644 lib/pages/people/person_detail_page.dart create mode 100644 lib/pages/people/person_form_page.dart create mode 100644 lib/pages/people/person_list_page.dart create mode 100644 lib/widgets/person_info_sheet.dart create mode 100644 lib/widgets/work_people_section.dart create mode 100644 lib/widgets/work_selector_page.dart diff --git a/lib/data/database_helper.dart b/lib/data/database_helper.dart index f345a49..d6ac2dd 100644 --- a/lib/data/database_helper.dart +++ b/lib/data/database_helper.dart @@ -81,7 +81,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 38, + version: 39, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -401,6 +401,10 @@ class DatabaseHelper { await db.execute('ALTER TABLE playlists ADD COLUMN sort_order INTEGER DEFAULT 0'); } } + if (oldVersion < 39) { + // 创建人物表和关联表 + await _createPeopleTables(db); + } } Future _upgradeBooksTableV26(Database db) async { final columns = await db.rawQuery('PRAGMA table_info(books)'); @@ -1033,6 +1037,74 @@ class DatabaseHelper { await db.execute( 'CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id)', ); + + // 人物表 + await _createPeopleTables(db); + } + + /// 创建人物表和关联表 + Future _createPeopleTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS people ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + gender TEXT, + birth_date TEXT, + birth_place TEXT, + alternate_names TEXT DEFAULT '[]', + occupation TEXT DEFAULT '[]', + summary TEXT, + photo_path TEXT, + cover_offset REAL DEFAULT 0, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS movie_people ( + id TEXT PRIMARY KEY, + movie_id TEXT NOT NULL, + person_id TEXT NOT NULL, + role_type TEXT NOT NULL, + character_name TEXT, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (movie_id) REFERENCES movies (id), + FOREIGN KEY (person_id) REFERENCES people (id) + ) + '''); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS book_people ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + person_id TEXT NOT NULL, + role_type TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES books (id), + FOREIGN KEY (person_id) REFERENCES people (id) + ) + '''); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS game_people ( + id TEXT PRIMARY KEY, + game_id TEXT NOT NULL, + person_id TEXT NOT NULL, + role_type TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (game_id) REFERENCES games (id), + FOREIGN KEY (person_id) REFERENCES people (id) + ) + '''); + + await db.execute('CREATE INDEX IF NOT EXISTS idx_movie_people_movie ON movie_people(movie_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_movie_people_person ON movie_people(person_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_book_people_book ON book_people(book_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_book_people_person ON book_people(person_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_game_people_game ON game_people(game_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_game_people_person ON game_people(person_id)'); } // 关闭数据库 diff --git a/lib/data/person/book_person_dao.dart b/lib/data/person/book_person_dao.dart new file mode 100644 index 0000000..1946a86 --- /dev/null +++ b/lib/data/person/book_person_dao.dart @@ -0,0 +1,87 @@ +import 'package:flutter/foundation.dart'; +import '../../models/data_models.dart'; +import '../database_helper.dart'; + +/// 书籍↔人物关联数据访问对象 +class BookPersonDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + Future _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[BookPersonDao] $op error: $e'); + rethrow; + } + } + + // 获取某本书的所有关联人物 + Future> getByBookId(String bookId) => _wrap('getByBookId', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'book_people', + where: 'book_id = ?', + whereArgs: [bookId], + orderBy: 'role_type, sort_order', + ); + return maps.map((m) => BookPerson.fromJson(m)).toList(); + }); + + // 获取某个人物参与的所有书籍关联 + Future> getByPersonId(String personId) => _wrap('getByPersonId', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'book_people', + where: 'person_id = ?', + whereArgs: [personId], + orderBy: 'sort_order', + ); + return maps.map((m) => BookPerson.fromJson(m)).toList(); + }); + + // 添加关联 + Future insert(BookPerson bookPerson) => _wrap('insert', () async { + final db = await _dbHelper.database; + return await db.insert('book_people', bookPerson.toJson()); + }); + + // 批量添加关联 + Future insertAll(List items) => _wrap('insertAll', () async { + final db = await _dbHelper.database; + final batch = db.batch(); + for (final item in items) { + batch.insert('book_people', item.toJson()); + } + await batch.commit(noResult: true); + }); + + // 删除某本书的所有关联 + Future deleteByBookId(String bookId) => _wrap('deleteByBookId', () async { + final db = await _dbHelper.database; + return await db.delete('book_people', where: 'book_id = ?', whereArgs: [bookId]); + }); + + // 删除某个人物的所有书籍关联 + Future deleteByPersonId(String personId) => _wrap('deleteByPersonId', () async { + final db = await _dbHelper.database; + return await db.delete('book_people', where: 'person_id = ?', whereArgs: [personId]); + }); + + // 删除单条关联 + Future deleteById(String id) => _wrap('deleteById', () async { + final db = await _dbHelper.database; + return await db.delete('book_people', where: 'id = ?', whereArgs: [id]); + }); + + // 检查关联是否已存在(避免重复插入) + Future existsRelation(String bookId, String personId, String roleType) => _wrap('existsRelation', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'book_people', + where: 'book_id = ? AND person_id = ? AND role_type = ?', + whereArgs: [bookId, personId, roleType], + limit: 1, + ); + return maps.isNotEmpty; + }); +} diff --git a/lib/data/person/game_person_dao.dart b/lib/data/person/game_person_dao.dart new file mode 100644 index 0000000..07d4dbb --- /dev/null +++ b/lib/data/person/game_person_dao.dart @@ -0,0 +1,87 @@ +import 'package:flutter/foundation.dart'; +import '../../models/data_models.dart'; +import '../database_helper.dart'; + +/// 游戏↔人物关联数据访问对象 +class GamePersonDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + Future _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[GamePersonDao] $op error: $e'); + rethrow; + } + } + + // 获取某游戏的所有关联人物 + Future> getByGameId(String gameId) => _wrap('getByGameId', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'game_people', + where: 'game_id = ?', + whereArgs: [gameId], + orderBy: 'sort_order', + ); + return maps.map((m) => GamePerson.fromJson(m)).toList(); + }); + + // 获取某个人物参与的所有游戏关联 + Future> getByPersonId(String personId) => _wrap('getByPersonId', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'game_people', + where: 'person_id = ?', + whereArgs: [personId], + orderBy: 'sort_order', + ); + return maps.map((m) => GamePerson.fromJson(m)).toList(); + }); + + // 添加关联 + Future insert(GamePerson gamePerson) => _wrap('insert', () async { + final db = await _dbHelper.database; + return await db.insert('game_people', gamePerson.toJson()); + }); + + // 批量添加关联 + Future insertAll(List items) => _wrap('insertAll', () async { + final db = await _dbHelper.database; + final batch = db.batch(); + for (final item in items) { + batch.insert('game_people', item.toJson()); + } + await batch.commit(noResult: true); + }); + + // 删除某游戏的所有关联 + Future deleteByGameId(String gameId) => _wrap('deleteByGameId', () async { + final db = await _dbHelper.database; + return await db.delete('game_people', where: 'game_id = ?', whereArgs: [gameId]); + }); + + // 删除某个人物的所有游戏关联 + Future deleteByPersonId(String personId) => _wrap('deleteByPersonId', () async { + final db = await _dbHelper.database; + return await db.delete('game_people', where: 'person_id = ?', whereArgs: [personId]); + }); + + // 删除单条关联 + Future deleteById(String id) => _wrap('deleteById', () async { + final db = await _dbHelper.database; + return await db.delete('game_people', where: 'id = ?', whereArgs: [id]); + }); + + // 检查关联是否已存在(避免重复插入) + Future existsRelation(String gameId, String personId, String roleType) => _wrap('existsRelation', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'game_people', + where: 'game_id = ? AND person_id = ? AND role_type = ?', + whereArgs: [gameId, personId, roleType], + limit: 1, + ); + return maps.isNotEmpty; + }); +} diff --git a/lib/data/person/movie_person_dao.dart b/lib/data/person/movie_person_dao.dart new file mode 100644 index 0000000..fc23ab1 --- /dev/null +++ b/lib/data/person/movie_person_dao.dart @@ -0,0 +1,110 @@ +import 'package:flutter/foundation.dart'; +import '../../models/data_models.dart'; +import '../database_helper.dart'; + +/// 影视↔人物关联数据访问对象 +class MoviePersonDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + Future _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[MoviePersonDao] $op error: $e'); + rethrow; + } + } + + // 获取某部影视的所有关联人物 + Future> getByMovieId(String movieId) => _wrap('getByMovieId', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'movie_people', + where: 'movie_id = ?', + whereArgs: [movieId], + orderBy: 'role_type, sort_order', + ); + return maps.map((m) => MoviePerson.fromJson(m)).toList(); + }); + + // 获取某个人物参与的所有影视关联 + Future> getByPersonId(String personId) => _wrap('getByPersonId', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'movie_people', + where: 'person_id = ?', + whereArgs: [personId], + orderBy: 'sort_order', + ); + return maps.map((m) => MoviePerson.fromJson(m)).toList(); + }); + + // 按影视+角色类型获取人物 + Future> getByMovieAndRole(String movieId, String roleType) => _wrap('getByMovieAndRole', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'movie_people', + where: 'movie_id = ? AND role_type = ?', + whereArgs: [movieId, roleType], + orderBy: 'sort_order', + ); + return maps.map((m) => MoviePerson.fromJson(m)).toList(); + }); + + // 添加关联 + Future insert(MoviePerson moviePerson) => _wrap('insert', () async { + final db = await _dbHelper.database; + return await db.insert('movie_people', moviePerson.toJson()); + }); + + // 批量添加关联 + Future insertAll(List items) => _wrap('insertAll', () async { + final db = await _dbHelper.database; + final batch = db.batch(); + for (final item in items) { + batch.insert('movie_people', item.toJson()); + } + await batch.commit(noResult: true); + }); + + // 更新关联(如修改角色名) + Future update(MoviePerson moviePerson) => _wrap('update', () async { + final db = await _dbHelper.database; + return await db.update( + 'movie_people', + moviePerson.toJson(), + where: 'id = ?', + whereArgs: [moviePerson.id], + ); + }); + + // 删除某部影视的所有关联 + Future deleteByMovieId(String movieId) => _wrap('deleteByMovieId', () async { + final db = await _dbHelper.database; + return await db.delete('movie_people', where: 'movie_id = ?', whereArgs: [movieId]); + }); + + // 删除某个人物的所有影视关联 + Future deleteByPersonId(String personId) => _wrap('deleteByPersonId', () async { + final db = await _dbHelper.database; + return await db.delete('movie_people', where: 'person_id = ?', whereArgs: [personId]); + }); + + // 删除单条关联 + Future deleteById(String id) => _wrap('deleteById', () async { + final db = await _dbHelper.database; + return await db.delete('movie_people', where: 'id = ?', whereArgs: [id]); + }); + + // 检查关联是否已存在(避免重复插入) + Future existsRelation(String movieId, String personId, String roleType) => _wrap('existsRelation', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'movie_people', + where: 'movie_id = ? AND person_id = ? AND role_type = ?', + whereArgs: [movieId, personId, roleType], + limit: 1, + ); + return maps.isNotEmpty; + }); +} diff --git a/lib/data/person/person_dao.dart b/lib/data/person/person_dao.dart new file mode 100644 index 0000000..7bd911b --- /dev/null +++ b/lib/data/person/person_dao.dart @@ -0,0 +1,313 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:sqflite/sqflite.dart'; +import '../../models/data_models.dart'; +import '../database_helper.dart'; + +/// 人物数据访问对象 +class PersonDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + Future _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[PersonDao] $op error: $e'); + rethrow; + } + } + + // 获取所有人物(未删除的) + Future> getAllPeople() => _wrap('getAllPeople', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'people', + where: 'is_deleted = ?', + whereArgs: [0], + orderBy: 'updated_at DESC', + ); + return maps.map((m) => Person.fromJson(m)).toList(); + }); + + // 搜索人物(名称或别名) + Future> searchPeople(String keyword) => _wrap('searchPeople', () async { + final db = await _dbHelper.database; + final like = '%$keyword%'; + final maps = await db.query( + 'people', + where: '(name LIKE ? OR alternate_names LIKE ?) AND is_deleted = ?', + whereArgs: [like, like, 0], + orderBy: 'updated_at DESC', + ); + return maps.map((m) => Person.fromJson(m)).toList(); + }); + + // 按职业筛选 + Future> getPeopleByOccupation(String occupation) => _wrap('getPeopleByOccupation', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'people', + where: 'occupation LIKE ? AND is_deleted = ?', + whereArgs: ['%$occupation%', 0], + orderBy: 'updated_at DESC', + ); + return maps.map((m) => Person.fromJson(m)) + .where((p) => p.occupation.contains(occupation)) + .toList(); + }); + + // 根据名称精确查找人物(未删除) + Future getPersonByName(String name) => _wrap('getPersonByName', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'people', + where: 'name = ? AND is_deleted = ?', + whereArgs: [name, 0], + limit: 1, + ); + if (maps.isEmpty) return null; + return Person.fromJson(maps.first); + }); + + // 根据ID获取人物 + Future getPersonById(String id) => _wrap('getPersonById', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'people', + where: 'id = ? AND is_deleted = ?', + whereArgs: [id, 0], + ); + if (maps.isEmpty) return null; + return Person.fromJson(maps.first); + }); + + // 添加人物 + Future insertPerson(Person person) => _wrap('insertPerson', () async { + final db = await _dbHelper.database; + return await db.insert('people', person.toJson()); + }); + + // 更新人物 + Future updatePerson(Person person) => _wrap('updatePerson', () async { + final db = await _dbHelper.database; + return await db.update( + 'people', + person.toJson(), + where: 'id = ?', + whereArgs: [person.id], + ); + }); + + // 仅更新封面偏移量 + Future updateCoverOffset(String personId, double offset) => _wrap('updateCoverOffset', () async { + final db = await _dbHelper.database; + await db.update('people', {'cover_offset': offset}, where: 'id = ?', whereArgs: [personId]); + }); + + // 软删除人物 + Future deletePerson(String id) => _wrap('deletePerson', () async { + final db = await _dbHelper.database; + return await db.update( + 'people', + {'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + }); + + // 恢复已删除的人物 + Future restorePerson(String id) => _wrap('restorePerson', () async { + final db = await _dbHelper.database; + return await db.update( + 'people', + {'is_deleted': 0, 'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + }); + + // 获取已删除的人物 + Future> getDeletedPeople() => _wrap('getDeletedPeople', () async { + final db = await _dbHelper.database; + final maps = await db.query( + 'people', + where: 'is_deleted = ?', + whereArgs: [1], + orderBy: 'updated_at DESC', + ); + return maps.map((m) => Person.fromJson(m)).toList(); + }); + + // 彻底删除人物 + Future permanentDeletePerson(String id) => _wrap('permanentDeletePerson', () async { + final db = await _dbHelper.database; + // 清理关联记录 + await db.delete('movie_people', where: 'person_id = ?', whereArgs: [id]); + await db.delete('book_people', where: 'person_id = ?', whereArgs: [id]); + await db.delete('game_people', where: 'person_id = ?', whereArgs: [id]); + return await db.delete('people', where: 'id = ?', whereArgs: [id]); + }); + + /// 合并同名人物:每个名字组保留信息最全的一条,迁移关联并去重,删除冗余。 + /// 返回被删除的冗余人物数量。 + Future mergeDuplicatePeople() => _wrap('mergeDuplicatePeople', () async { + final db = await _dbHelper.database; + // 取所有人物(含已删除),按 name 分组 + final maps = await db.query('people', orderBy: 'updated_at DESC'); + final byName = >>{}; + for (final m in maps) { + final name = (m['name'] as String?)?.trim() ?? ''; + if (name.isEmpty) continue; + byName.putIfAbsent(name, () => []).add(m); + } + + int removed = 0; + final now = DateTime.now().toUtc().toIso8601String(); + + for (final entry in byName.entries) { + final group = entry.value; + if (group.length < 2) continue; + + // 选保留者:优先未删除的、updated_at 最新的;信息更全的优先 + group.sort((a, b) { + final aDel = (a['is_deleted'] as int?) == 1; + final bDel = (b['is_deleted'] as int?) == 1; + if (aDel != bDel) return aDel ? 1 : -1; // 未删除的排前 + final aScore = _infoScore(a); + final bScore = _infoScore(b); + if (aScore != bScore) return bScore - aScore; // 信息多的排前 + return 0; + }); + final keeperRow = group.first; + final keeperId = keeperRow['id'] as String; + final dupes = group.skip(1).toList(); + + // 合并字段到 keeper + final merged = Map.from(keeperRow); + for (final dupe in dupes) { + _mergeFieldsInto(merged, dupe); + } + merged['updated_at'] = now; + await db.update('people', merged, where: 'id = ?', whereArgs: [keeperId]); + + // 迁移关联并去重 + for (final dupe in dupes) { + final dupeId = dupe['id'] as String; + await _migrateRelations(db, dupeId, keeperId, 'movie_people', 'movie_id'); + await _migrateRelations(db, dupeId, keeperId, 'book_people', 'book_id'); + await _migrateRelations(db, dupeId, keeperId, 'game_people', 'game_id'); + // 删除冗余人物 + await db.delete('people', where: 'id = ?', whereArgs: [dupeId]); + removed++; + } + } + return removed; + }); + + // 信息完整度评分(非空字段越多分越高) + int _infoScore(Map row) { + int score = 0; + if ((row['photo_path'] as String?)?.isNotEmpty == true) score += 5; + if ((row['summary'] as String?)?.isNotEmpty == true) score += 3; + if ((row['gender'] as String?)?.isNotEmpty == true) score += 1; + if ((row['birth_date'] as String?)?.isNotEmpty == true) score += 1; + if ((row['birth_place'] as String?)?.isNotEmpty == true) score += 1; + final alt = (row['alternate_names'] as String?) ?? ''; + if (alt.isNotEmpty && alt != '[]') score += 1; + final occ = (row['occupation'] as String?) ?? ''; + if (occ.isNotEmpty && occ != '[]') score += 1; + return score; + } + + // 把 dupe 的非空字段并进 merged(merged 已有的非空值不覆盖) + void _mergeFieldsInto(Map merged, Map dupe) { + void mergeField(String key) { + final cur = merged[key]; + final curEmpty = cur == null || (cur is String && cur.isEmpty); + final dup = dupe[key]; + final dupEmpty = dup == null || (dup is String && dup.isEmpty); + if (curEmpty && !dupEmpty) merged[key] = dup; + } + mergeField('photo_path'); + mergeField('summary'); + mergeField('gender'); + mergeField('birth_date'); + mergeField('birth_place'); + + // 列表字段取并集 + merged['alternate_names'] = _unionListJson(merged['alternate_names'], dupe['alternate_names']); + merged['occupation'] = _unionListJson(merged['occupation'], dupe['occupation']); + } + + String _unionListJson(Object? a, Object? b) { + final set = {}; + for (final raw in [a, b]) { + if (raw == null) continue; + final s = raw.toString(); + if (s.isEmpty || s == '[]') continue; + try { + final list = jsonDecode(s); + if (list is List) { + for (final e in list) { + if (e != null) set.add(e.toString()); + } + } + } catch (_) {} + } + return jsonEncode(set.toList()); + } + + // 把 fromPersonId 的关联迁移到 toPersonId,按 (work_id, role_type) 去重 + Future _migrateRelations( + Database db, + String fromPersonId, + String toPersonId, + String table, + String workIdCol, + ) async { + // 取 dupe 的所有关联 + final dupeRows = await db.query( + table, + columns: [workIdCol, 'role_type'], + where: 'person_id = ?', + whereArgs: [fromPersonId], + ); + // 取 keeper 已有的关联键集合 + final keeperRows = await db.query( + table, + columns: [workIdCol, 'role_type'], + where: 'person_id = ?', + whereArgs: [toPersonId], + ); + final keeperKeys = {}; + for (final r in keeperRows) { + keeperKeys.add('${r[workIdCol]}|${r['role_type']}'); + } + // 把不与 keeper 冲突的关联改指向 keeper + for (final r in dupeRows) { + final key = '${r[workIdCol]}|${r['role_type']}'; + if (keeperKeys.contains(key)) continue; + await db.update( + table, + {'person_id': toPersonId}, + where: 'person_id = ? AND $workIdCol = ? AND role_type = ?', + whereArgs: [fromPersonId, r[workIdCol], r['role_type']], + ); + keeperKeys.add(key); + } + // 删除剩余的(与 keeper 冲突的)关联 + await db.delete(table, where: 'person_id = ?', whereArgs: [fromPersonId]); + } + + // 获取所有人物名称(去重,供选择器使用) + Future> getAllPersonNames() => _wrap('getAllPersonNames', () async { + final people = await getAllPeople(); + final names = {}; + for (final p in people) { + names.add(p.name); + names.addAll(p.alternateNames); + } + return names.toList()..sort(); + }); +} diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 5065830..07744a5 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -1143,3 +1143,225 @@ class PlaylistItem { } } +/// 人物档案模型 +class Person { + final String id; + final String name; + final String? gender; // male/female/other + final DateTime? birthDate; + final String? birthPlace; + final List alternateNames; // 其他名称 + final List occupation; // 职业: ["导演","演员","编剧"] + final String? summary; + final String? photoPath; + final double coverOffset; + final bool isDeleted; + final DateTime createdAt; + final DateTime updatedAt; + + Person({ + required this.id, + required this.name, + this.gender, + this.birthDate, + this.birthPlace, + this.alternateNames = const [], + this.occupation = const [], + this.summary, + this.photoPath, + this.coverOffset = 0.0, + this.isDeleted = false, + required this.createdAt, + required this.updatedAt, + }); + + factory Person.fromJson(Map json) { + return Person( + id: json['id'] ?? '', + name: json['name'] ?? '', + gender: json['gender'], + birthDate: _safeParseDate(json['birth_date']), + birthPlace: json['birth_place'], + alternateNames: parseStringListGeneric(json['alternate_names']), + occupation: parseStringListGeneric(json['occupation']), + summary: json['summary'], + photoPath: json['photo_path'], + coverOffset: _safeParseDouble(json['cover_offset'], fallback: 0.0)!, + isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'gender': gender, + 'birth_date': birthDate?.toUtc().toIso8601String(), + 'birth_place': birthPlace, + 'alternate_names': jsonEncode(alternateNames), + 'occupation': jsonEncode(occupation), + 'summary': summary, + 'photo_path': photoPath, + 'cover_offset': coverOffset, + 'is_deleted': isDeleted ? 1 : 0, + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), + }; + } + + Person copyWith({ + String? id, + String? name, + Object? gender = _copyWithNull, + DateTime? birthDate, + Object? birthPlace = _copyWithNull, + List? alternateNames, + List? occupation, + Object? summary = _copyWithNull, + Object? photoPath = _copyWithNull, + double? coverOffset, + bool? isDeleted, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Person( + id: id ?? this.id, + name: name ?? this.name, + gender: gender is _CopyWithNullSentinel ? this.gender : (gender as String?), + birthDate: birthDate ?? this.birthDate, + birthPlace: birthPlace is _CopyWithNullSentinel ? this.birthPlace : (birthPlace as String?), + alternateNames: alternateNames ?? this.alternateNames, + occupation: occupation ?? this.occupation, + summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?), + photoPath: photoPath is _CopyWithNullSentinel ? this.photoPath : (photoPath as String?), + coverOffset: coverOffset ?? this.coverOffset, + isDeleted: isDeleted ?? this.isDeleted, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + /// 获取封面文件 + File? get photoFile { + if (photoPath == null || photoPath!.isEmpty) return null; + return File(photoPath!); + } +} + +/// 影视↔人物关联模型 +class MoviePerson { + final String id; + final String movieId; + final String personId; + final String roleType; // director/writer/actor + final String? characterName; // 饰演角色(仅actor) + final int sortOrder; + + MoviePerson({ + required this.id, + required this.movieId, + required this.personId, + required this.roleType, + this.characterName, + this.sortOrder = 0, + }); + + factory MoviePerson.fromJson(Map json) { + return MoviePerson( + id: json['id'] ?? '', + movieId: json['movie_id'] ?? '', + personId: json['person_id'] ?? '', + roleType: json['role_type'] ?? 'actor', + characterName: json['character_name'], + sortOrder: json['sort_order'] ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'movie_id': movieId, + 'person_id': personId, + 'role_type': roleType, + 'character_name': characterName, + 'sort_order': sortOrder, + }; + } +} + +/// 书籍↔人物关联模型 +class BookPerson { + final String id; + final String bookId; + final String personId; + final String roleType; // author/translator + final int sortOrder; + + BookPerson({ + required this.id, + required this.bookId, + required this.personId, + required this.roleType, + this.sortOrder = 0, + }); + + factory BookPerson.fromJson(Map json) { + return BookPerson( + id: json['id'] ?? '', + bookId: json['book_id'] ?? '', + personId: json['person_id'] ?? '', + roleType: json['role_type'] ?? 'author', + sortOrder: json['sort_order'] ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'book_id': bookId, + 'person_id': personId, + 'role_type': roleType, + 'sort_order': sortOrder, + }; + } +} + +/// 游戏↔人物关联模型 +class GamePerson { + final String id; + final String gameId; + final String personId; + final String roleType; // developer + final int sortOrder; + + GamePerson({ + required this.id, + required this.gameId, + required this.personId, + required this.roleType, + this.sortOrder = 0, + }); + + factory GamePerson.fromJson(Map json) { + return GamePerson( + id: json['id'] ?? '', + gameId: json['game_id'] ?? '', + personId: json['person_id'] ?? '', + roleType: json['role_type'] ?? 'developer', + sortOrder: json['sort_order'] ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'game_id': gameId, + 'person_id': personId, + 'role_type': roleType, + 'sort_order': sortOrder, + }; + } +} + diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index ec90b1c..f3a0838 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -15,6 +15,7 @@ import '../../utils/user_prefs.dart'; import '../../utils/image_path_helper.dart'; import '../../utils/responsive.dart'; import '../../widgets/genre_selector_page.dart'; +import '../../widgets/work_people_section.dart'; import 'book_reviews_page.dart'; import 'book_excerpts_page.dart'; import 'book_share_page.dart'; @@ -267,6 +268,7 @@ class _BookDetailPageState extends State { Expanded(child: Text('${book.readCount} 次', style: TextStyle(fontSize: 13, color: colors.onSurface))), ]), ], + WorkPeopleSection(workId: book.id, workType: 'book'), if (book.summary != null && book.summary!.isNotEmpty) ...[ Divider(height: 32, thickness: 0.5, color: colors.outline), Row(children: [ @@ -806,7 +808,7 @@ class _BookDetailPageState extends State { if (book.publisher != null && book.publisher!.isNotEmpty) _buildPublisherSection(book), if (book.publishDate != null) _buildPublishDateSection(book), if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book), - Divider(height: 0.5, thickness: 0.5, color: colors.outline), + WorkPeopleSection(workId: book.id, workType: 'book'), if (book.summary != null && book.summary!.isNotEmpty) _buildSummarySection(book), Divider(height: 0.5, thickness: 0.5, color: colors.outline), _buildExtraSections(book), @@ -909,12 +911,15 @@ class _BookDetailPageState extends State { if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book), // 类型标签毛玻璃 if (book.genres.isNotEmpty) _buildGenresSection(book), + // 关联人物 + WorkPeopleSection(workId: book.id, workType: 'book'), // 简介:内部已有毛玻璃卡片 if (book.summary != null && book.summary!.isNotEmpty) ...[ const SizedBox(height: 12), _buildSummarySection(book), ], const SizedBox(height: 12), + const SizedBox(height: 12), // 书评、书摘毛玻璃 _buildExtraSectionsOverlay(book), ], diff --git a/lib/pages/game/game_detail_page.dart b/lib/pages/game/game_detail_page.dart index 4f751f0..83caafc 100644 --- a/lib/pages/game/game_detail_page.dart +++ b/lib/pages/game/game_detail_page.dart @@ -15,6 +15,7 @@ import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; import '../../utils/responsive.dart'; import '../../widgets/genre_selector_page.dart'; +import '../../widgets/work_people_section.dart'; import 'game_reviews_page.dart'; import 'game_screenshots_page.dart'; import 'game_share_page.dart'; @@ -272,6 +273,7 @@ class _GameDetailPageState extends State { _buildDesktopInfoRow('购买时间', _formatDate(game.purchaseDate!), colors), if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty) _buildDesktopInfoRow('购买价格', game.purchasePrice!, colors), + WorkPeopleSection(workId: game.id, workType: 'game'), if (game.summary != null && game.summary!.isNotEmpty) ...[ Divider(height: 32, thickness: 0.5, color: colors.outline), Row(children: [ @@ -997,6 +999,7 @@ class _GameDetailPageState extends State { _buildInfoSection('购买时间', _formatDate(game.purchaseDate!)), if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty) _buildInfoSection('购买价格', game.purchasePrice!), + WorkPeopleSection(workId: game.id, workType: 'game'), if (game.summary != null && game.summary!.isNotEmpty) _buildInfoSection('游戏简介', game.summary!), Divider(height: 0.5, thickness: 0.5, color: colors.outline), @@ -1146,11 +1149,14 @@ class _GameDetailPageState extends State { _buildOverlayInfoRow('购买时间', _formatDate(game.purchaseDate!)), if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty) _buildOverlayInfoRow('购买价格', game.purchasePrice!), + // 关联人物 + WorkPeopleSection(workId: game.id, workType: 'game'), if (game.summary != null && game.summary!.isNotEmpty) ...[ const SizedBox(height: 12), _buildOverlaySummary(game), ], const SizedBox(height: 12), + const SizedBox(height: 12), _buildExtraSectionsOverlay(game), ]), ), diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index 00749fd..b6b2cee 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -15,6 +15,7 @@ import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; import '../../utils/responsive.dart'; import '../../widgets/genre_selector_page.dart'; +import '../../widgets/work_people_section.dart'; import 'movie_reviews_page.dart'; import 'movie_posters_page.dart'; import 'movie_share_page.dart'; @@ -267,6 +268,7 @@ class _MovieDetailPageState extends State { )), ]), ], + WorkPeopleSection(workId: movie.id, workType: 'movie'), if (movie.summary != null && movie.summary!.isNotEmpty) ...[ Divider(height: 32, thickness: 0.5, color: colors.outline), Row(children: [ @@ -932,7 +934,7 @@ class _MovieDetailPageState extends State { _buildActorsSection(movie), if (movie.genres.isNotEmpty) _buildGenresSection(movie), - Divider(height: 0.5, thickness: 0.5, color: colors.outline), + WorkPeopleSection(workId: movie.id, workType: 'movie'), if (movie.summary != null && movie.summary!.isNotEmpty) _buildSummarySection(movie), Divider(height: 0.5, thickness: 0.5, color: colors.outline), @@ -1064,11 +1066,13 @@ class _MovieDetailPageState extends State { const SizedBox(height: 12), _buildGenresSection(movie), ], + const SizedBox(height: 12), + // 关联人物 + WorkPeopleSection(workId: movie.id, workType: 'movie'), + const SizedBox(height: 12), // 简介:内部已有毛玻璃卡片 - if (movie.summary != null && movie.summary!.isNotEmpty) ...[ - const SizedBox(height: 12), + if (movie.summary != null && movie.summary!.isNotEmpty) _buildSummarySection(movie), - ], const SizedBox(height: 12), // 影评、海报墙毛玻璃 _buildExtraSectionsOverlay(movie), diff --git a/lib/pages/people/person_detail_page.dart b/lib/pages/people/person_detail_page.dart new file mode 100644 index 0000000..9b0fc3f --- /dev/null +++ b/lib/pages/people/person_detail_page.dart @@ -0,0 +1,500 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/data_models.dart'; +import '../../providers/app_provider.dart'; +import '../../utils/toast_util.dart'; +import '../../widgets/fade_in_local_image.dart'; +import '../../widgets/work_selector_page.dart'; +import '../movies/movie_detail_page.dart'; +import '../book/book_detail_page.dart'; +import '../game/game_detail_page.dart'; +import 'person_form_page.dart'; + +/// 人物档案详情页 +class PersonDetailPage extends StatefulWidget { + final Person person; + + const PersonDetailPage({super.key, required this.person}); + + @override + State createState() => _PersonDetailPageState(); +} + +class _PersonDetailPageState extends State { + List _moviePeople = []; + List _bookPeople = []; + List _gamePeople = []; + bool _loading = true; + bool _summaryExpanded = false; + + @override + void initState() { + super.initState(); + _loadRelations(); + } + + Future _loadRelations() async { + final provider = context.read(); + final personId = widget.person.id; + final movies = provider.getPersonMovies(personId); + final books = provider.getPersonBooks(personId); + final games = provider.getPersonGames(personId); + final results = await Future.wait([movies, books, games]); + if (!mounted) return; + setState(() { + _moviePeople = results[0] as List; + _bookPeople = results[1] as List; + _gamePeople = results[2] as List; + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final person = context.watch().people + .where((p) => p.id == widget.person.id) + .firstOrNull ?? widget.person; + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: Text(person.name), + actions: [ + IconButton( + icon: const Icon(Icons.add_link_outlined), + tooltip: '关联作品', + onPressed: () => _editWorks(), + ), + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: '编辑', + onPressed: () => _navigateToEdit(person), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: '删除', + onPressed: () => _showDeleteDialog(person), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 头部:头像 + 基本信息 + _buildHeader(person, colors), + const SizedBox(height: 24), + + // 详细信息 + if (person.gender != null) _buildInfoRow('性别', _genderLabel(person.gender!), colors), + if (person.occupation.isNotEmpty) _buildInfoRow('职业', person.occupation.join(' / '), colors), + if (person.birthPlace != null) _buildInfoRow('出生地', person.birthPlace!, colors), + if (person.birthDate != null) _buildInfoRow('出生日期', _formatDate(person.birthDate!), colors), + if (person.alternateNames.isNotEmpty) _buildInfoRow('其他名称', person.alternateNames.join('、'), colors), + + // 简介 + if (person.summary != null && person.summary!.isNotEmpty) ...[ + const SizedBox(height: 16), + Divider(height: 0.5, thickness: 0.5, color: colors.outline), + const SizedBox(height: 16), + Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text('简介', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + const SizedBox(height: 12), + _buildSummary(person.summary!, colors), + ], + + // 作品列表 + if (!_loading) ...[ + const SizedBox(height: 16), + Divider(height: 0.5, thickness: 0.5, color: colors.outline), + const SizedBox(height: 16), + Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text('作品', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + const SizedBox(height: 12), + _buildWorksSection(colors), + ], + ], + ), + ), + ); + } + + Widget _buildHeader(Person person, ColorScheme colors) { + final hasPhoto = person.photoPath != null && person.photoPath!.isNotEmpty; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 头像 + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + child: hasPhoto + ? FadeInLocalImage(path: person.photoPath, fit: BoxFit.cover) + : Center( + child: Text( + person.name.isNotEmpty ? person.name[0] : '?', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w600, + color: colors.onSurface.withValues(alpha: 0.3), + ), + ), + ), + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Text( + person.name, + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w600, color: colors.onSurface), + ), + if (person.occupation.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 4, + children: person.occupation.map((occ) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Text(occ, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.6))), + )).toList(), + ), + ], + ], + ), + ), + ], + ); + } + + Widget _buildInfoRow(String label, String value, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 72, + child: Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), + ), + Expanded( + child: Text(value, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.5)), + ), + ], + ), + ); + } + + Widget _buildSummary(String summary, ColorScheme colors) { + const int previewLimit = 100; + final needsToggle = summary.length > previewLimit; + final displayText = _summaryExpanded || !needsToggle + ? summary + : '${summary.substring(0, previewLimit)}…'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(displayText, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.8)), + if (needsToggle) ...[ + const SizedBox(height: 6), + GestureDetector( + onTap: () => setState(() => _summaryExpanded = !_summaryExpanded), + child: Text( + _summaryExpanded ? '收起' : '展开', + style: TextStyle(fontSize: 13, color: colors.primary), + ), + ), + ], + ], + ); + } + + Widget _buildWorksSection(ColorScheme colors) { + final provider = context.read(); + + // 按作品 ID 分组,合并多角色 + final movieGroups = >{}; + for (final mp in _moviePeople) { + movieGroups.putIfAbsent(mp.movieId, () => []).add(mp); + } + final bookGroups = >{}; + for (final bp in _bookPeople) { + bookGroups.putIfAbsent(bp.bookId, () => []).add(bp); + } + final gameGroups = >{}; + for (final gp in _gamePeople) { + gameGroups.putIfAbsent(gp.gameId, () => []).add(gp); + } + + final totalWorks = movieGroups.length + bookGroups.length + gameGroups.length; + + if (totalWorks == 0) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Center( + child: Text('暂无关联作品', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3))), + ), + ); + } + + String joinRoles(List roles) => roles.join(' / '); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 影视作品 + if (movieGroups.isNotEmpty) ...[ + Text('影视 (${movieGroups.length})', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 8), + ...movieGroups.entries.map((entry) { + final movie = provider.movies.where((m) => m.id == entry.key).firstOrNull; + if (movie == null) return const SizedBox.shrink(); + // 按 (roleType, characterName) 去重 + final seen = {}; + final roles = []; + for (final mp in entry.value) { + final key = '${mp.roleType}|${mp.characterName ?? ''}'; + if (!seen.add(key)) continue; + final label = _roleTypeLabel(mp.roleType); + if (mp.characterName != null && mp.characterName!.isNotEmpty) { + roles.add('$label 饰 ${mp.characterName}'); + } else { + roles.add(label); + } + } + return _buildWorkItem( + title: movie.title, + subtitle: joinRoles(roles), + posterPath: movie.posterPath, + colors: colors, + onTap: () => Navigator.push(context, MaterialPageRoute( + builder: (_) => MovieDetailPage(movie: movie), + )), + ); + }), + const SizedBox(height: 16), + ], + // 书籍作品 + if (bookGroups.isNotEmpty) ...[ + Text('书籍 (${bookGroups.length})', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 8), + ...bookGroups.entries.map((entry) { + final book = provider.books.where((b) => b.id == entry.key).firstOrNull; + if (book == null) return const SizedBox.shrink(); + final seen = {}; + final roles = []; + for (final bp in entry.value) { + if (!seen.add(bp.roleType)) continue; + roles.add(_roleTypeLabel(bp.roleType)); + } + return _buildWorkItem( + title: book.title, + subtitle: joinRoles(roles), + posterPath: book.coverPath, + colors: colors, + onTap: () => Navigator.push(context, MaterialPageRoute( + builder: (_) => BookDetailPage(book: book), + )), + ); + }), + const SizedBox(height: 16), + ], + // 游戏作品 + if (gameGroups.isNotEmpty) ...[ + Text('游戏 (${gameGroups.length})', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 8), + ...gameGroups.entries.map((entry) { + final game = provider.games.where((g) => g.id == entry.key).firstOrNull; + if (game == null) return const SizedBox.shrink(); + final seen = {}; + final roles = []; + for (final gp in entry.value) { + if (!seen.add(gp.roleType)) continue; + roles.add(_roleTypeLabel(gp.roleType)); + } + return _buildWorkItem( + title: game.title, + subtitle: joinRoles(roles), + posterPath: game.coverPath, + colors: colors, + onTap: () => Navigator.push(context, MaterialPageRoute( + builder: (_) => GameDetailPage(game: game), + )), + ); + }), + ], + ], + ); + } + + Widget _buildWorkItem({ + required String title, + required String subtitle, + String? posterPath, + required ColorScheme colors, + VoidCallback? onTap, + }) { + final hasPoster = posterPath != null && posterPath.isNotEmpty; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Container( + width: 36, + height: 50, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + clipBehavior: Clip.antiAlias, + child: hasPoster + ? FadeInLocalImage(path: posterPath, fit: BoxFit.cover) + : Center(child: Icon(Icons.movie_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.2))), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface), + maxLines: 1, overflow: TextOverflow.ellipsis), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(subtitle, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)), + maxLines: 1, overflow: TextOverflow.ellipsis), + ], + ], + ), + ), + ], + ), + ), + ); + } + + String _genderLabel(String gender) { + return switch (gender) { + 'male' => '男', + 'female' => '女', + 'other' => '其他', + _ => gender, + }; + } + + String _roleTypeLabel(String roleType) { + return switch (roleType) { + 'director' => '导演', + 'writer' => '编剧', + 'actor' => '演员', + 'author' => '作者', + 'translator' => '译者', + 'developer' => '开发者', + _ => roleType, + }; + } + + String _formatDate(DateTime date) { + return '${date.year}年${date.month.toString().padLeft(2, '0')}月${date.day.toString().padLeft(2, '0')}日'; + } + + void _navigateToEdit(Person person) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PersonFormPage(person: person), + ), + ).then((_) { + if (mounted) { + context.read().loadPeople(); + _loadRelations(); + } + }); + } + + Future _editWorks() async { + final result = await WorkSelectorPage.show( + context: context, + personId: widget.person.id, + initialMovies: _moviePeople, + initialBooks: _bookPeople, + initialGames: _gamePeople, + ); + if (result == null || !mounted) return; + + final provider = context.read(); + await Future.wait([ + provider.savePersonMovieRelations(widget.person.id, result.movies), + provider.savePersonBookRelations(widget.person.id, result.books), + provider.savePersonGameRelations(widget.person.id, result.games), + ]); + if (!mounted) return; + await _loadRelations(); + if (mounted) ToastUtil.show(context, '作品关联已更新'); + } + + void _showDeleteDialog(Person person) { + final colors = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要删除"${person.name}"吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), + ), + ElevatedButton( + onPressed: () async { + final provider = this.context.read(); + await provider.removePerson(person.id); + if (!mounted || !context.mounted) return; + Navigator.pop(context); // close dialog + Navigator.pop(this.context); // close detail page + ToastUtil.show(this.context, '已删除'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('删除'), + ), + ], + ), + ); + } +} diff --git a/lib/pages/people/person_form_page.dart b/lib/pages/people/person_form_page.dart new file mode 100644 index 0000000..f83adf1 --- /dev/null +++ b/lib/pages/people/person_form_page.dart @@ -0,0 +1,602 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:http/http.dart' as http; +import 'package:uuid/uuid.dart'; +import '../../models/data_models.dart'; +import '../../providers/app_provider.dart'; +import '../../utils/image_path_helper.dart'; +import '../../utils/toast_util.dart'; +import '../../widgets/fade_in_local_image.dart'; +import '../../widgets/genre_selector_page.dart'; + +/// 人物编辑/添加页面 +class PersonFormPage extends StatefulWidget { + final Person? person; + + const PersonFormPage({super.key, this.person}); + + @override + State createState() => _PersonFormPageState(); +} + +class _PersonFormPageState extends State { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _summaryCtrl = TextEditingController(); + final _birthPlaceCtrl = TextEditingController(); + final ImagePicker _picker = ImagePicker(); + + String? _gender; + DateTime? _birthDate; + String? _birthPlace; + List _alternateNames = []; + List _occupation = []; + String? _photoPath; + bool _isDownloading = false; + + static const _genderOptions = [ + ('男', 'male'), + ('女', 'female'), + ('其他', 'other'), + ]; + + static const _occupationOptions = [ + '导演', '编剧', '演员', '制片人', '摄影师', + '作者', '译者', '开发者', '配音', '其他', + ]; + + @override + void initState() { + super.initState(); + if (widget.person != null) { + final p = widget.person!; + _nameCtrl.text = p.name; + _summaryCtrl.text = p.summary ?? ''; + _birthPlaceCtrl.text = p.birthPlace ?? ''; + _gender = p.gender; + _birthDate = p.birthDate; + _birthPlace = p.birthPlace; + _alternateNames = List.from(p.alternateNames); + _occupation = List.from(p.occupation); + _photoPath = p.photoPath; + } + } + + @override + void dispose() { + _nameCtrl.dispose(); + _summaryCtrl.dispose(); + _birthPlaceCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final isEdit = widget.person != null; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmLeave(); + if (shouldPop && context.mounted) Navigator.pop(context); + }, + child: Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: Text(isEdit ? '编辑人物' : '添加人物'), + actions: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: FilledButton( + onPressed: _save, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('保存'), + ), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + children: [ + // 头像 + Center(child: _buildPhotoPicker(colors)), + const SizedBox(height: 24), + + // 名称 + _buildField('名称', _nameCtrl, hint: '人物名称', required: true), + const SizedBox(height: 16), + + // 性别 + _buildSectionLabel('性别', colors), + const SizedBox(height: 6), + Wrap( + spacing: 8, + children: _genderOptions.map((opt) { + final selected = _gender == opt.$2; + return GestureDetector( + onTap: () => setState(() => _gender = selected ? null : opt.$2), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: selected ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + opt.$1, + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w500 : FontWeight.normal, + color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5), + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + + // 出生日期 + _buildDateField('出生日期', _birthDate, (d) => setState(() => _birthDate = d), colors, clearable: true), + const SizedBox(height: 16), + + // 出生地 + _buildField('出生地', _birthPlaceCtrl, hint: '如:北京', + onChanged: (v) => _birthPlace = v.isEmpty ? null : v), + const SizedBox(height: 16), + + // 其他名称 + _buildChipField('其他名称', _alternateNames, colors, onTap: () async { + final result = await GenreSelectorPage.show( + context: context, + title: '添加其他名称', + existingTags: [], + initialSelected: _alternateNames, + hint: '如:艺名、英文名', + ); + if (result != null) setState(() => _alternateNames = result); + }), + const SizedBox(height: 16), + + // 职业 + _buildChipField('职业', _occupation, colors, onTap: () async { + final result = await GenreSelectorPage.show( + context: context, + title: '选择职业', + existingTags: _occupationOptions, + initialSelected: _occupation, + hint: '如:导演、演员', + ); + if (result != null) setState(() => _occupation = result); + }), + const SizedBox(height: 16), + + // 简介 + _buildSectionLabel('人物简介', colors), + const SizedBox(height: 6), + Container( + constraints: const BoxConstraints(minHeight: 120), + child: TextFormField( + controller: _summaryCtrl, + maxLines: null, + style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.6), + decoration: InputDecoration( + hintText: '写下人物简介...', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), + filled: true, + fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.all(12), + ), + ), + ), + const SizedBox(height: 48), + ], + ), + ), + ), + ); + } + + Widget _buildPhotoPicker(ColorScheme colors) { + final hasPhoto = _photoPath != null && _photoPath!.isNotEmpty; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _showPhotoOptions, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + child: Stack( + alignment: Alignment.center, + children: [ + if (hasPhoto) + FadeInLocalImage(path: _photoPath, fit: BoxFit.cover) + else + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_add_outlined, size: 32, color: colors.onSurface.withValues(alpha: 0.25)), + const SizedBox(height: 4), + Text('添加图片', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + ], + ), + if (_isDownloading) + Container( + color: Colors.black.withValues(alpha: 0.4), + child: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + ], + ), + ), + ), + if (hasPhoto) + Padding( + padding: const EdgeInsets.only(top: 8), + child: GestureDetector( + onTap: () => setState(() => _photoPath = null), + child: Text('移除图片', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))), + ), + ), + ], + ); + } + + void _showPhotoOptions() { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 40, height: 4, decoration: BoxDecoration(color: colors.outline, borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Align(alignment: Alignment.centerLeft, + child: Text('添加图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface))), + ), + const SizedBox(height: 16), + ListTile( + leading: Icon(Icons.photo_library_outlined, color: colors.onSurface.withValues(alpha: 0.6)), + title: Text('从相册选择', style: TextStyle(color: colors.onSurface)), + onTap: () { Navigator.pop(ctx); _pickPhoto(); }, + ), + ListTile( + leading: Icon(Icons.link_outlined, color: colors.onSurface.withValues(alpha: 0.6)), + title: Text('网络链接', style: TextStyle(color: colors.onSurface)), + onTap: () { Navigator.pop(ctx); _pickPhotoFromUrl(); }, + ), + ], + ), + ), + ), + ); + } + + Future _pickPhoto() async { + try { + final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, maxWidth: 600, maxHeight: 600, imageQuality: 85); + if (picked == null) return; + final fileName = 'photo_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final personId = widget.person?.id ?? const Uuid().v4(); + final targetPath = await ImagePathHelper.instance.getPersonPhotoPath(personId, fileName); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + await File(picked.path).copy(targetPath); + if (mounted) setState(() => _photoPath = targetPath); + } catch (e) { + if (mounted) ToastUtil.show(context, '选择图片失败: $e'); + } + } + + Future _pickPhotoFromUrl() async { + String? url; + final confirmed = await showDialog(context: context, builder: (ctx) { + final urlCtrl = TextEditingController(); + final colors = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('添加网络图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('请输入图片链接地址', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(height: 12), + TextField( + controller: urlCtrl, + keyboardType: TextInputType.url, + style: TextStyle(fontSize: 14, color: colors.onSurface), + decoration: InputDecoration( + hintText: 'https://example.com/image.jpg', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), + filled: true, fillColor: colors.surfaceContainerHigh, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + ), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () { url = urlCtrl.text.trim(); Navigator.pop(ctx, true); }, + style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('确定'), + ), + ], + ); + }); + if (confirmed != true || url == null || url!.isEmpty) return; + await _downloadPhotoFromUrl(url!); + } + + Future _downloadPhotoFromUrl(String url) async { + setState(() => _isDownloading = true); + try { + final response = await http.get( + Uri.parse(url), + headers: { + 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Referer': Uri.parse(url).replace(path: '/').toString(), + }, + ); + + if (response.statusCode != 200) throw Exception('下载失败: HTTP ${response.statusCode}'); + + final contentType = response.headers['content-type']; + if (contentType != null && !contentType.startsWith('image/')) throw Exception('链接返回的不是图片'); + if (response.bodyBytes.length > 10 * 1024 * 1024) throw Exception('图片太大'); + + final fileName = 'photo_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final personId = widget.person?.id ?? const Uuid().v4(); + final targetPath = await ImagePathHelper.instance.getPersonPhotoPath(personId, fileName); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + await File(targetPath).writeAsBytes(response.bodyBytes); + + if (!mounted) return; + setState(() => _photoPath = targetPath); + } catch (e) { + debugPrint('头像下载失败: $e'); + if (mounted) ToastUtil.show(context, '下载失败: $e'); + } finally { + if (mounted) setState(() => _isDownloading = false); + } + } + + Widget _buildSectionLabel(String label, ColorScheme colors) { + return Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))); + } + + Widget _buildField(String label, TextEditingController ctrl, {String hint = '', bool required = false, ValueChanged? onChanged}) { + final colors = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(required ? '$label *' : label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 6), + TextFormField( + controller: ctrl, + style: TextStyle(fontSize: 14, color: colors.onSurface), + validator: required ? (v) => (v == null || v.trim().isEmpty) ? '请输入$label' : null : null, + onChanged: onChanged, + decoration: InputDecoration( + hintText: hint, hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), + filled: true, fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + isDense: true, + ), + ), + ], + ); + } + + Widget _buildDateField(String label, DateTime? date, ValueChanged onChanged, ColorScheme colors, {bool clearable = false}) { + final hasDate = date != null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 6), + GestureDetector( + onTap: () async { + final picked = await showDatePicker( + context: context, initialDate: date ?? DateTime.now(), + firstDate: DateTime(1800), lastDate: DateTime.now(), + ); + if (picked != null) onChanged(picked); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.calendar_today_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + const SizedBox(width: 8), + Text( + hasDate ? '${date!.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}' : '选择日期', + style: TextStyle(fontSize: 14, color: hasDate ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25)), + ), + const Spacer(), + if (clearable && hasDate) + GestureDetector( + onTap: () => onChanged(null), + child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.3)), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildChipField(String label, List chips, ColorScheme colors, {required VoidCallback onTap}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 6), + GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + ), + child: chips.isEmpty + ? Text('点击选择$label', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25))) + : Wrap( + spacing: 4, runSpacing: 4, + children: chips.map((c) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(4)), + child: Text(c, style: TextStyle(fontSize: 12, color: colors.onSurface)), + )).toList(), + ), + ), + ), + ], + ); + } + + bool _hasContent() { + if (widget.person != null) return true; + if (_nameCtrl.text.trim().isNotEmpty) return true; + if (_summaryCtrl.text.trim().isNotEmpty) return true; + if (_photoPath != null) return true; + if (_gender != null || _birthDate != null || _birthPlace != null) return true; + if (_alternateNames.isNotEmpty || _occupation.isNotEmpty) return true; + return false; + } + + Future _confirmLeave() async { + if (!_hasContent()) return true; + final colors = Theme.of(context).colorScheme; + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('未保存', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('当前内容未保存,确定要离开吗?', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('离开'), + ), + ], + ), + ); + return result ?? false; + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + try { + final now = DateTime.now(); + final provider = context.read(); + + if (widget.person == null) { + // 新建 + final newId = const Uuid().v4(); + String? finalPhotoPath; + if (_photoPath != null && _photoPath!.isNotEmpty) { + finalPhotoPath = await _movePhotoToNewId(_photoPath!, newId); + } + + final person = Person( + id: newId, + name: _nameCtrl.text.trim(), + gender: _gender, + birthDate: _birthDate, + birthPlace: _birthPlace, + alternateNames: _alternateNames, + occupation: _occupation, + summary: _summaryCtrl.text.trim().isEmpty ? null : _summaryCtrl.text.trim(), + photoPath: finalPhotoPath, + createdAt: now, + updatedAt: now, + ); + await provider.addPerson(person); + } else { + // 编辑 + final updated = widget.person!.copyWith( + name: _nameCtrl.text.trim(), + gender: _gender, + birthDate: _birthDate, + birthPlace: _birthPlace, + alternateNames: _alternateNames, + occupation: _occupation, + summary: _summaryCtrl.text.trim().isEmpty ? null : _summaryCtrl.text.trim(), + photoPath: _photoPath, + updatedAt: now, + ); + await provider.updatePerson(updated); + } + + if (!mounted) return; + ToastUtil.show(context, widget.person == null ? '添加成功' : '更新成功'); + Navigator.pop(context); + } catch (e) { + if (!mounted) return; + ToastUtil.show(context, '保存失败: $e'); + } + } + + Future _movePhotoToNewId(String currentPath, String newPersonId) async { + final normalizedPath = currentPath.replaceAll('\\', '/'); + if (normalizedPath.contains('/people/$newPersonId/')) return currentPath; + + final fileName = p.basename(currentPath); + final newPath = await ImagePathHelper.instance.getPersonPhotoPath(newPersonId, fileName); + await ImagePathHelper.instance.ensureDirExists(p.dirname(newPath)); + + final currentFile = File(currentPath); + if (await currentFile.exists()) { + await currentFile.rename(newPath); + // 清理临时目录 + final tempDir = Directory(p.dirname(currentPath)); + if (await tempDir.exists()) { + try { await tempDir.delete(recursive: true); } catch (_) {} + } + return newPath; + } + return null; + } +} diff --git a/lib/pages/people/person_list_page.dart b/lib/pages/people/person_list_page.dart new file mode 100644 index 0000000..fd0502e --- /dev/null +++ b/lib/pages/people/person_list_page.dart @@ -0,0 +1,375 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/data_models.dart'; +import '../../providers/app_provider.dart'; +import '../../utils/responsive.dart'; +import '../../utils/toast_util.dart'; +import '../../widgets/fade_in_local_image.dart'; +import 'person_detail_page.dart'; +import 'person_form_page.dart'; + +/// 人物列表页 +class PersonListPage extends StatefulWidget { + const PersonListPage({super.key}); + + @override + State createState() => _PersonListPageState(); +} + +class _PersonListPageState extends State { + String _searchKeyword = ''; + String? _occupationFilter; // 职业筛选 + final TextEditingController _searchCtrl = TextEditingController(); + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + List _filterPeople(List people) { + var result = people; + if (_occupationFilter != null) { + result = result.where((p) => p.occupation.contains(_occupationFilter)).toList(); + } + if (_searchKeyword.isNotEmpty) { + final kw = _searchKeyword.toLowerCase(); + result = result.where((p) { + if (p.name.toLowerCase().contains(kw)) return true; + if (p.alternateNames.any((n) => n.toLowerCase().contains(kw))) return true; + return false; + }).toList(); + } + return result; + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final people = context.watch().people; + final filtered = _filterPeople(people); + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: const Text('人物'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: '扫描作品自动关联', + onPressed: () => _refreshRelations(), + ), + IconButton( + icon: const Icon(Icons.add_outlined), + tooltip: '添加人物', + onPressed: () => _navigateToForm(), + ), + ], + ), + body: Column( + children: [ + // 搜索栏 + _buildSearchBar(colors), + // 职业筛选 + _buildOccupationFilter(colors, people), + // 列表 + Expanded( + child: filtered.isEmpty + ? _buildEmptyState(colors) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: filtered.length, + itemBuilder: (context, index) => _buildPersonItem(filtered[index], colors), + ), + ), + ], + ), + ); + } + + Widget _buildSearchBar(ColorScheme colors) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Container( + height: 44, + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + ), + child: Row( + children: [ + const SizedBox(width: 16), + Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.3)), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: _searchCtrl, + style: TextStyle(fontSize: 14, color: colors.onSurface), + cursorColor: colors.primary, + decoration: InputDecoration( + hintText: '搜索人物名称或别名', + hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + filled: false, + ), + onChanged: (v) => setState(() => _searchKeyword = v), + ), + ), + if (_searchKeyword.isNotEmpty) + GestureDetector( + onTap: () { + _searchCtrl.clear(); + setState(() => _searchKeyword = ''); + FocusManager.instance.primaryFocus?.unfocus(); + }, + child: Container( + margin: const EdgeInsets.only(right: 10), + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + ), + ) + else + const SizedBox(width: 16), + ], + ), + ), + ); + } + + Widget _buildOccupationFilter(ColorScheme colors, List people) { + // 收集所有职业及其数量 + final occupationCounts = {}; + for (final p in people) { + for (final occ in p.occupation) { + occupationCounts[occ] = (occupationCounts[occ] ?? 0) + 1; + } + } + if (occupationCounts.isEmpty) return const SizedBox.shrink(); + + final sorted = occupationCounts.keys.toList()..sort(); + return SizedBox( + height: 36, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + children: [ + _buildFilterChip('全部', null, people.length, colors), + for (final occ in sorted) + _buildFilterChip(occ, occ, occupationCounts[occ] ?? 0, colors), + ], + ), + ); + } + + Widget _buildFilterChip(String label, String? value, int count, ColorScheme colors) { + final selected = _occupationFilter == value; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => setState(() => _occupationFilter = value), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w500 : FontWeight.normal, + color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 4), + Text( + '$count', + style: TextStyle( + fontSize: 11, + color: selected ? colors.onPrimary.withValues(alpha: 0.7) : colors.onSurface.withValues(alpha: 0.3), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildPersonItem(Person person, ColorScheme colors) { + final hasPhoto = person.photoPath != null && person.photoPath!.isNotEmpty; + return InkWell( + onTap: () => _navigateToDetail(person), + borderRadius: BorderRadius.circular(10), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + // 头像 + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + child: hasPhoto + ? FadeInLocalImage(path: person.photoPath, fit: BoxFit.cover) + : Center( + child: Text( + person.name.isNotEmpty ? person.name[0] : '?', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: colors.onSurface.withValues(alpha: 0.4), + ), + ), + ), + ), + const SizedBox(width: 12), + // 信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: colors.onSurface), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (person.occupation.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + person.occupation.join(' / '), + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (person.alternateNames.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + '又名:${person.alternateNames.join('、')}', + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.2)), + ], + ), + ), + ); + } + + Widget _buildEmptyState(ColorScheme colors) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.people_outline, size: 64, color: colors.onSurface.withValues(alpha: 0.15)), + const SizedBox(height: 16), + Text( + _searchKeyword.isNotEmpty ? '未找到匹配的人物' : '暂无人物', + style: TextStyle(fontSize: 15, color: colors.onSurface.withValues(alpha: 0.3)), + ), + if (_searchKeyword.isEmpty) ...[ + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: () => _navigateToForm(), + child: const Text('添加人物'), + ), + ], + ], + ), + ); + } + + void _navigateToDetail(Person person) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PersonDetailPage(person: person), + ), + ); + } + + Future _refreshRelations() async { + final provider = context.read(); + // 显示加载弹窗 + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => PopScope( + canPop: false, + child: Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(ctx).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ), + ), + ), + ); + try { + final result = await provider.refreshPersonRelations(); + if (!mounted) return; + Navigator.pop(context); // 关闭加载弹窗 + if (result.newPersons == 0 && result.newRelations == 0 && result.merged == 0) { + ToastUtil.show(context, '已是最新,无新增关联'); + } else { + final parts = []; + if (result.merged > 0) parts.add('合并 ${result.merged} 个重复人物'); + if (result.newPersons > 0) parts.add('新增 ${result.newPersons} 个人物'); + if (result.newRelations > 0) parts.add('${result.newRelations} 条关联'); + ToastUtil.show(context, parts.join(',')); + } + } catch (e) { + if (!mounted) return; + Navigator.pop(context); + ToastUtil.show(context, '刷新失败:$e'); + } + } + + void _navigateToForm([Person? person]) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PersonFormPage(person: person), + ), + ).then((_) { + if (mounted) context.read().loadPeople(); + }); + } +} diff --git a/lib/pages/profile/feature_settings_page.dart b/lib/pages/profile/feature_settings_page.dart index b077e3e..85fa117 100644 --- a/lib/pages/profile/feature_settings_page.dart +++ b/lib/pages/profile/feature_settings_page.dart @@ -292,7 +292,7 @@ class _FeatureSettingsPageState extends State { endIndent: 24, color: colors.outlineVariant), _buildSwitchItem( - Icons.people_outline, '角色信息', '管理影视和书籍中的角色', _showPerson, + Icons.people_outline, '人物', '管理影视、书籍和游戏中的人物', _showPerson, (v) async { await _userPrefs.setShowSidebarPerson(v); setState(() => _showPerson = v); diff --git a/lib/pages/settings/recycle_bin_page.dart b/lib/pages/settings/recycle_bin_page.dart index 8124c1e..580ad44 100644 --- a/lib/pages/settings/recycle_bin_page.dart +++ b/lib/pages/settings/recycle_bin_page.dart @@ -12,7 +12,7 @@ class RecycleBinPage extends StatefulWidget { State createState() => _RecycleBinPageState(); } -enum _ItemType { movie, book, note, game, movieReview, bookReview, bookExcerpt, gameReview } +enum _ItemType { movie, book, note, game, movieReview, bookReview, bookExcerpt, gameReview, person } class _DeletedItem { final _ItemType type; @@ -86,6 +86,14 @@ class _DeletedItem { icon = Icons.rate_review_outlined, typeLabel = '游戏评价'; + _DeletedItem.person(Person p) + : type = _ItemType.person, + id = p.id, + title = p.name, + subtitle = '删除于 ${p.updatedAt.year}.${p.updatedAt.month.toString().padLeft(2, '0')}.${p.updatedAt.day.toString().padLeft(2, '0')}', + icon = Icons.person_outline, + typeLabel = '人物'; + } class _RecycleBinPageState extends State { @@ -113,6 +121,7 @@ class _RecycleBinPageState extends State { final bookReviews = await provider.getDeletedBookReviews(); final bookExcerpts = await provider.getDeletedBookExcerpts(); final gameReviews = await provider.getDeletedGameReviews(); + final people = await provider.getDeletedPeople(); if (!mounted) return; setState(() { _allItems = [ @@ -124,6 +133,7 @@ class _RecycleBinPageState extends State { for (final r in bookReviews) _DeletedItem.bookReview(r), for (final e in bookExcerpts) _DeletedItem.bookExcerpt(e), for (final r in gameReviews) _DeletedItem.gameReview(r), + for (final p in people) _DeletedItem.person(p), ]; _isLoading = false; }); @@ -200,6 +210,7 @@ class _RecycleBinPageState extends State { _filterChip('书评', _ItemType.bookReview), _filterChip('书摘', _ItemType.bookExcerpt), _filterChip('游戏评价', _ItemType.gameReview), + _filterChip('人物', _ItemType.person), ], ), ); @@ -428,6 +439,9 @@ class _RecycleBinPageState extends State { case _ItemType.gameReview: await provider.restoreGameReview(item.id); if (mounted) ToastUtil.show(context, '游戏评价已恢复'); + case _ItemType.person: + await provider.restorePerson(item.id); + if (mounted) ToastUtil.show(context, '人物已恢复'); } _loadDeletedItems(); } @@ -453,6 +467,8 @@ class _RecycleBinPageState extends State { await provider.permanentDeleteBookExcerpt(item.id); case _ItemType.gameReview: await provider.permanentDeleteGameReview(item.id); + case _ItemType.person: + await provider.permanentDeletePerson(item.id); } _loadDeletedItems(); if (mounted) ToastUtil.show(context, '已彻底删除'); diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index c9e1ecf..f85cdcd 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1,6 +1,7 @@ import 'dart:collection'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; import '../models/data_models.dart'; import '../data/movie/movie_dao.dart'; import '../data/book/book_dao.dart'; @@ -14,6 +15,10 @@ import '../data/game/game_review_dao.dart'; import '../data/game/game_screenshot_dao.dart'; import '../data/playlist/playlist_dao.dart'; import '../data/tag/tag_dao.dart'; +import '../data/person/person_dao.dart'; +import '../data/person/movie_person_dao.dart'; +import '../data/person/book_person_dao.dart'; +import '../data/person/game_person_dao.dart'; import '../data/database_helper.dart'; import '../utils/image_path_helper.dart'; import '../utils/user_prefs.dart'; @@ -35,12 +40,17 @@ class AppProvider extends ChangeNotifier { final GameScreenshotDao _gameScreenshotDao = GameScreenshotDao(); final PlaylistDao _playlistDao = PlaylistDao(); final TagDao _tagDao = TagDao(); + final PersonDao _personDao = PersonDao(); + final MoviePersonDao _moviePersonDao = MoviePersonDao(); + final BookPersonDao _bookPersonDao = BookPersonDao(); + final GamePersonDao _gamePersonDao = GamePersonDao(); // 数据列表 List _movies = []; List _books = []; List _notes = []; List _games = []; List _playlists = []; + List _people = []; // 当前主界面选中的标签 (0: 观影,1: 阅读,2: 笔记) int _mainTabIndex = 0; @@ -154,6 +164,11 @@ class AppProvider extends ChangeNotifier { } catch (e) { debugPrint('[AppProvider] 加载游戏数据失败: $e'); } + try { + _people = await _personDao.getAllPeople(); + } catch (e) { + debugPrint('[AppProvider] 加载人物数据失败: $e'); + } // 检查是否全部失败 if (_movies.isEmpty && _books.isEmpty && _notes.isEmpty && _games.isEmpty) { // 可能是初始化全部失败(非空数据库场景下不合理),标记以便 UI 提示 @@ -165,7 +180,7 @@ class AppProvider extends ChangeNotifier { _dbInitFailed = true; } } - debugPrint('[AppProvider] 本地数据: movies=${_movies.length}, books=${_books.length}, notes=${_notes.length}, games=${_games.length}'); + debugPrint('[AppProvider] 本地数据: movies=${_movies.length}, books=${_books.length}, notes=${_notes.length}, games=${_games.length}, people=${_people.length}'); notifyListeners(); } @@ -240,6 +255,12 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + // 加载人物数据 + Future loadPeople() async { + _people = await _personDao.getAllPeople(); + notifyListeners(); + } + /// 编辑返回后触发列表页重载 /// [itemId] 被编辑条目的 ID,用于就地更新而非重置分页 void setEditRefresh([String? itemId]) { @@ -295,6 +316,7 @@ class AppProvider extends ChangeNotifier { List get notes => UnmodifiableListView(_notes); List get games => UnmodifiableListView(_games); List get playlists => UnmodifiableListView(_playlists); + List get people => UnmodifiableListView(_people); // 根据状态获取影视列表 List getMoviesByStatus(String status) { @@ -933,12 +955,14 @@ class AppProvider extends ChangeNotifier { final deletedBookReviews = await getDeletedBookReviews(); final deletedBookExcerpts = await getDeletedBookExcerpts(); final deletedGameReviews = await getDeletedGameReviews(); + final deletedPeople = await getDeletedPeople(); // 先收集需要删除图片的 ID,再在事务中批量删除数据库记录 final movieIds = deletedMovies.map((m) => m.id).toList(); final bookIds = deletedBooks.map((b) => b.id).toList(); final noteIds = deletedNotes.map((n) => n.id).toList(); final gameIds = deletedGames.map((g) => g.id).toList(); + final personIds = deletedPeople.map((p) => p.id).toList(); // 事务内批量删除数据库记录,保证原子性 final db = await DatabaseHelper.instance.database; @@ -973,6 +997,12 @@ class AppProvider extends ChangeNotifier { for (final review in deletedGameReviews) { await txn.delete('game_reviews', where: 'id = ?', whereArgs: [review.id]); } + for (final id in personIds) { + await txn.delete('movie_people', where: 'person_id = ?', whereArgs: [id]); + await txn.delete('book_people', where: 'person_id = ?', whereArgs: [id]); + await txn.delete('game_people', where: 'person_id = ?', whereArgs: [id]); + await txn.delete('people', where: 'id = ?', whereArgs: [id]); + } }); // 事务成功后,清理关联的图片文件(文件删除失败不影响数据一致性) @@ -988,12 +1018,16 @@ class AppProvider extends ChangeNotifier { for (final id in gameIds) { await ImagePathHelper.instance.deleteGameImages(id); } + for (final id in personIds) { + await ImagePathHelper.instance.deletePersonImages(id); + } await loadMovies(); await loadBooks(); await loadNotes(); await loadGames(); await loadPlaylists(); + await loadPeople(); } // ========== 影评书评回收站 ========== @@ -1161,4 +1195,258 @@ class AppProvider extends ChangeNotifier { await loadGames(); } } + + // ========== 人物管理 ========== + + /// 添加人物 + Future addPerson(Person person) async { + await _personDao.insertPerson(person); + await loadPeople(); + } + + /// 更新人物 + Future updatePerson(Person person) async { + await _personDao.updatePerson(person); + await loadPeople(); + } + + /// 更新人物封面偏移量 + Future updatePersonCoverOffset(String personId, double offset) async { + await _personDao.updateCoverOffset(personId, offset); + } + + /// 软删除人物 + Future removePerson(String id) async { + await _personDao.deletePerson(id); + await loadPeople(); + } + + /// 恢复已删除的人物 + Future restorePerson(String id) async { + await _personDao.restorePerson(id); + await loadPeople(); + } + + /// 彻底删除人物 + Future permanentDeletePerson(String id) async { + await ImagePathHelper.instance.deletePersonImages(id); + await _personDao.permanentDeletePerson(id); + } + + /// 获取已删除的人物 + Future> getDeletedPeople() async { + return await _personDao.getDeletedPeople(); + } + + /// 搜索人物 + Future> searchPeople(String keyword) async { + return await _personDao.searchPeople(keyword); + } + + /// 根据ID获取人物 + Future getPersonById(String id) async { + return await _personDao.getPersonById(id); + } + + // ========== 人物关联查询 ========== + + /// 获取某部影视的关联人物 + Future> getMoviePeople(String movieId) async { + return await _moviePersonDao.getByMovieId(movieId); + } + + /// 获取某个人物参与的影视 + Future> getPersonMovies(String personId) async { + return await _moviePersonDao.getByPersonId(personId); + } + + /// 获取某本书的关联人物 + Future> getBookPeople(String bookId) async { + return await _bookPersonDao.getByBookId(bookId); + } + + /// 获取某个人物参与的书籍 + Future> getPersonBooks(String personId) async { + return await _bookPersonDao.getByPersonId(personId); + } + + /// 获取某游戏的关联人物 + Future> getGamePeople(String gameId) async { + return await _gamePersonDao.getByGameId(gameId); + } + + /// 获取某个人物参与的游戏 + Future> getPersonGames(String personId) async { + return await _gamePersonDao.getByPersonId(personId); + } + + /// 保存影视的人物关联(先删后插) + Future saveMoviePeople(String movieId, List people) async { + await _moviePersonDao.deleteByMovieId(movieId); + if (people.isNotEmpty) { + await _moviePersonDao.insertAll(people); + } + } + + /// 保存书籍的人物关联 + Future saveBookPeople(String bookId, List people) async { + await _bookPersonDao.deleteByBookId(bookId); + if (people.isNotEmpty) { + await _bookPersonDao.insertAll(people); + } + } + + /// 保存游戏的人物关联 + Future saveGamePeople(String gameId, List people) async { + await _gamePersonDao.deleteByGameId(gameId); + if (people.isNotEmpty) { + await _gamePersonDao.insertAll(people); + } + } + + /// 保存某个人物的所有影视关联(先删后插,按 personId 维度) + Future savePersonMovieRelations(String personId, List relations) async { + await _moviePersonDao.deleteByPersonId(personId); + if (relations.isNotEmpty) { + await _moviePersonDao.insertAll(relations); + } + } + + /// 保存某个人物的所有书籍关联 + Future savePersonBookRelations(String personId, List relations) async { + await _bookPersonDao.deleteByPersonId(personId); + if (relations.isNotEmpty) { + await _bookPersonDao.insertAll(relations); + } + } + + /// 保存某个人物的所有游戏关联 + Future savePersonGameRelations(String personId, List relations) async { + await _gamePersonDao.deleteByPersonId(personId); + if (relations.isNotEmpty) { + await _gamePersonDao.insertAll(relations); + } + } + + /// 扫描所有作品的人物字段,自动创建人物并建立关联 + /// 返回 (新增人物数, 新增关联数, 合并去重数) + Future<({int newPersons, int newRelations, int merged})> refreshPersonRelations() async { + // 先合并已有的同名重复人物 + final merged = await _personDao.mergeDuplicatePeople(); + if (merged > 0) { + await loadPeople(); + } + + final nameIndex = {}; + for (final p in _people) { + nameIndex[p.name] = p; + } + + int newPersons = 0; + int newRelations = 0; + const uuid = Uuid(); + final now = DateTime.now(); + + Future findOrCreate(String name, String occupationLabel) async { + final existing = nameIndex[name]; + if (existing != null) return existing; + // 内存索引未命中时回退查库,避免重复创建同名人物 + final dbExisting = await _personDao.getPersonByName(name); + if (dbExisting != null) { + nameIndex[name] = dbExisting; + return dbExisting; + } + final person = Person( + id: uuid.v4(), + name: name, + occupation: [occupationLabel], + createdAt: now, + updatedAt: now, + ); + await _personDao.insertPerson(person); + nameIndex[name] = person; + newPersons++; + return person; + } + + // 影视:directors/writers/actors + for (final movie in _movies) { + if (movie.isDeleted) continue; + final entries = <(List, String, String)>[ + (movie.directors, 'director', '导演'), + (movie.writers, 'writer', '编剧'), + (movie.actors, 'actor', '演员'), + ]; + for (final (names, roleType, occupationLabel) in entries) { + final seen = {}; + for (final name in names) { + final trimmed = name.trim(); + if (trimmed.isEmpty || !seen.add(trimmed)) continue; + final person = await findOrCreate(trimmed, occupationLabel); + if (!await _moviePersonDao.existsRelation(movie.id, person.id, roleType)) { + await _moviePersonDao.insert(MoviePerson( + id: uuid.v4(), + movieId: movie.id, + personId: person.id, + roleType: roleType, + characterName: null, + sortOrder: 0, + )); + newRelations++; + } + } + } + } + + // 书籍:authors/translators + for (final book in _books) { + if (book.isDeleted) continue; + final entries = <(List, String, String)>[ + (book.authors, 'author', '作者'), + (book.translators, 'translator', '译者'), + ]; + for (final (names, roleType, occupationLabel) in entries) { + final seen = {}; + for (final name in names) { + final trimmed = name.trim(); + if (trimmed.isEmpty || !seen.add(trimmed)) continue; + final person = await findOrCreate(trimmed, occupationLabel); + if (!await _bookPersonDao.existsRelation(book.id, person.id, roleType)) { + await _bookPersonDao.insert(BookPerson( + id: uuid.v4(), + bookId: book.id, + personId: person.id, + roleType: roleType, + sortOrder: 0, + )); + newRelations++; + } + } + } + } + + // 游戏:developer + for (final game in _games) { + if (game.isDeleted) continue; + final seen = {}; + for (final name in game.developer) { + final trimmed = name.trim(); + if (trimmed.isEmpty || !seen.add(trimmed)) continue; + final person = await findOrCreate(trimmed, '开发者'); + if (!await _gamePersonDao.existsRelation(game.id, person.id, 'developer')) { + await _gamePersonDao.insert(GamePerson( + id: uuid.v4(), + gameId: game.id, + personId: person.id, + roleType: 'developer', + sortOrder: 0, + )); + newRelations++; + } + } + } + + await loadPeople(); + return (newPersons: newPersons, newRelations: newRelations, merged: merged); + } } diff --git a/lib/services/sync/backup_service.dart b/lib/services/sync/backup_service.dart index 9a01a18..8350232 100644 --- a/lib/services/sync/backup_service.dart +++ b/lib/services/sync/backup_service.dart @@ -71,6 +71,10 @@ class BackupService { final games = await db.query('games'); final gameReviews = await db.query('game_reviews'); final gameScreenshots = await db.query('game_screenshots'); + final people = await db.query('people'); + final moviePeople = await db.query('movie_people'); + final bookPeople = await db.query('book_people'); + final gamePeople = await db.query('game_people'); // 收集图片路径 final imagePaths = {}; @@ -109,6 +113,10 @@ class BackupService { final p = s['screenshot_path'] as String?; if (p != null && p.isNotEmpty) imagePaths.add(p); } + for (final p in people) { + final pp = p['photo_path'] as String?; + if (pp != null && pp.isNotEmpty) imagePaths.add(pp); + } final userPrefs = UserPrefs(); final userInfo = { @@ -141,6 +149,10 @@ class BackupService { 'games': games, 'game_reviews': gameReviews, 'game_screenshots': gameScreenshots, + 'people': people, + 'movie_people': moviePeople, + 'book_people': bookPeople, + 'game_people': gamePeople, }, }; @@ -420,6 +432,10 @@ class BackupService { final gamesCols = await _getTableColumns(db, 'games'); final gameReviewsCols = await _getTableColumns(db, 'game_reviews'); final gameScreenshotsCols = await _getTableColumns(db, 'game_screenshots'); + final peopleCols = await _getTableColumns(db, 'people'); + final moviePeopleCols = await _getTableColumns(db, 'movie_people'); + final bookPeopleCols = await _getTableColumns(db, 'book_people'); + final gamePeopleCols = await _getTableColumns(db, 'game_people'); await db.transaction((txn) async { await txn.delete('movie_reviews'); @@ -429,6 +445,10 @@ class BackupService { await txn.delete('book_annotations'); await txn.delete('game_reviews'); await txn.delete('game_screenshots'); + await txn.delete('movie_people'); + await txn.delete('book_people'); + await txn.delete('game_people'); + await txn.delete('people'); await txn.delete('movies'); await txn.delete('books'); await txn.delete('notes'); @@ -576,6 +596,10 @@ class BackupService { final gamesCols = await _getTableColumns(db, 'games'); final gameReviewsCols = await _getTableColumns(db, 'game_reviews'); final gameScreenshotsCols = await _getTableColumns(db, 'game_screenshots'); + final peopleCols = await _getTableColumns(db, 'people'); + final moviePeopleCols = await _getTableColumns(db, 'movie_people'); + final bookPeopleCols = await _getTableColumns(db, 'book_people'); + final gamePeopleCols = await _getTableColumns(db, 'game_people'); await db.transaction((txn) async { await txn.delete('movie_reviews'); @@ -585,6 +609,10 @@ class BackupService { await txn.delete('book_annotations'); await txn.delete('game_reviews'); await txn.delete('game_screenshots'); + await txn.delete('movie_people'); + await txn.delete('book_people'); + await txn.delete('game_people'); + await txn.delete('people'); await txn.delete('movies'); await txn.delete('books'); await txn.delete('notes'); diff --git a/lib/utils/app_router.dart b/lib/utils/app_router.dart index 828f256..f0f1da6 100644 --- a/lib/utils/app_router.dart +++ b/lib/utils/app_router.dart @@ -12,6 +12,7 @@ import '../pages/book/book_detail_page.dart'; import '../pages/note/note_detail_page.dart'; import '../pages/game/game_detail_page.dart'; import '../pages/movies/douban_webview_page.dart'; +import '../pages/people/person_form_page.dart'; /// 路由生成器 class AppRouter { @@ -89,6 +90,11 @@ class AppRouter { } return SlideUpPageRoute(page: DoubanWebViewPage(url: url)); + case '/person-form': + final args = settings.arguments; + final Person? person = args is Person ? args : null; + return SlideUpPageRoute(page: PersonFormPage(person: person)); + default: return _buildUnknownRoute(settings.name); } diff --git a/lib/utils/image_path_helper.dart b/lib/utils/image_path_helper.dart index 779d269..2859ff9 100644 --- a/lib/utils/image_path_helper.dart +++ b/lib/utils/image_path_helper.dart @@ -12,6 +12,7 @@ import 'package:path/path.dart' as p; /// images/notes/{noteId}/xxxx.jpg - 笔记图片 /// images/games/{gameId}/xxxx.jpg - 游戏封面 /// images/games/{gameId}/screenshots/xxxx.jpg - 游戏截图 +/// images/people/{personId}/xxxx.jpg - 人物图片 class ImagePathHelper { static final ImagePathHelper instance = ImagePathHelper._init(); @@ -130,6 +131,22 @@ class ImagePathHelper { return p.join(dir, fileName); } + // ==================== 人物相关路径 ==================== + + /// 获取人物图片目录 + /// 路径: images/people/{personId}/ + Future getPersonImagesDir(String personId) async { + final root = await imagesRoot; + return p.join(root, 'people', personId); + } + + /// 获取人物图片路径 + /// 路径: images/people/{personId}/{fileName} + Future getPersonPhotoPath(String personId, String fileName) async { + final dir = await getPersonImagesDir(personId); + return p.join(dir, fileName); + } + // ==================== 目录操作 ==================== /// 确保目录存在 @@ -173,6 +190,13 @@ class ImagePathHelper { await _deleteDirectory(dirPath); } + /// 删除人物图片目录 + /// 删除路径: images/people/{personId}/ + Future deletePersonImages(String personId) async { + final dirPath = await getPersonImagesDir(personId); + await _deleteDirectory(dirPath); + } + /// 删除目录及其内容 Future _deleteDirectory(String dirPath) async { try { diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index d48a660..b58e344 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -10,7 +10,7 @@ import '../pages/explore/stroll_page.dart'; import '../pages/explore/reviewed_page.dart'; import '../pages/playlist/playlist_list_page.dart'; import '../pages/explore/media_calendar_page.dart'; -import '../pages/explore/person_list_page.dart'; +import '../pages/people/person_list_page.dart'; import '../pages/markdown_reader/md_reader_tab_page.dart'; import '../pages/epub_reader/epub_library_page.dart'; import '../pages/settings/tag_management_page.dart'; @@ -330,7 +330,7 @@ class _CustomDrawerState extends State { if (userPrefs.showSidebarCalendar) exploreItems.add((SvgPicture.string('', color: colors.onSurface), '书影日历', const MediaCalendarPage())); final toolItems = <(Widget, String, Widget)>[]; - if (userPrefs.showSidebarPerson) toolItems.add((SvgPicture.string('', color: colors.onSurface), '角色信息', const PersonListPage())); + if (userPrefs.showSidebarPerson) toolItems.add((SvgPicture.string('', color: colors.onSurface), '人物', const PersonListPage())); if (userPrefs.showSidebarTags) toolItems.add((SvgPicture.string('', color: colors.onSurface), '标签管理', const TagManagementPage())); if (userPrefs.showSidebarMdReader) toolItems.add((Icon(Icons.description_outlined, size: 20, color: colors.onSurface), 'MD阅读', const MdReaderTabPage())); if (userPrefs.showSidebarEpub) toolItems.add((SvgPicture.string('', color: colors.onSurface), 'EPUB阅读', const EpubLibraryPage())); diff --git a/lib/widgets/person_info_sheet.dart b/lib/widgets/person_info_sheet.dart new file mode 100644 index 0000000..fe61512 --- /dev/null +++ b/lib/widgets/person_info_sheet.dart @@ -0,0 +1,434 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/data_models.dart'; +import '../pages/people/person_detail_page.dart'; +import '../providers/app_provider.dart'; +import 'fade_in_local_image.dart'; + +/// 人物信息浮动面板(底部 ModalBottomSheet) +/// 展示人物基本信息 + 关联作品,点击「查看全部」跳转到原详情页 +class PersonInfoSheet extends StatefulWidget { + final Person person; + + const PersonInfoSheet({super.key, required this.person}); + + static Future show(BuildContext context, Person person) { + return showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => PersonInfoSheet(person: person), + ); + } + + @override + State createState() => _PersonInfoSheetState(); +} + +class _PersonInfoSheetState extends State { + List _moviePeople = []; + List _bookPeople = []; + List _gamePeople = []; + bool _loading = true; + bool _summaryExpanded = false; + + @override + void initState() { + super.initState(); + _loadRelations(); + } + + Future _loadRelations() async { + final provider = context.read(); + final personId = widget.person.id; + final results = await Future.wait([ + provider.getPersonMovies(personId), + provider.getPersonBooks(personId), + provider.getPersonGames(personId), + ]); + if (!mounted) return; + setState(() { + _moviePeople = results[0] as List; + _bookPeople = results[1] as List; + _gamePeople = results[2] as List; + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final person = context.watch().people + .where((p) => p.id == widget.person.id) + .firstOrNull ?? widget.person; + + final maxHeight = MediaQuery.of(context).size.height * 0.8; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 拖拽条 + Center( + child: Container( + width: 32, + height: 3, + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 12), + // 顶部:头像 + 名字 + 查看全部 + _buildHeader(person, colors), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 详细信息 + if (person.gender != null) _buildInfoRow('性别', _genderLabel(person.gender!), colors), + if (person.occupation.isNotEmpty) _buildInfoRow('职业', person.occupation.join(' / '), colors), + if (person.birthPlace != null) _buildInfoRow('出生地', person.birthPlace!, colors), + if (person.birthDate != null) _buildInfoRow('出生日期', _formatDate(person.birthDate!), colors), + if (person.alternateNames.isNotEmpty) _buildInfoRow('其他名称', person.alternateNames.join('、'), colors), + + // 简介 + if (person.summary != null && person.summary!.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildSectionTitle('简介', colors), + const SizedBox(height: 8), + _buildSummary(person.summary!, colors), + ], + + // 作品 + if (!_loading) ...[ + const SizedBox(height: 16), + _buildSectionTitle('作品', colors), + const SizedBox(height: 8), + _buildWorksSection(colors), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHeader(Person person, ColorScheme colors) { + final hasPhoto = person.photoPath != null && person.photoPath!.isNotEmpty; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + child: hasPhoto + ? FadeInLocalImage(path: person.photoPath, fit: BoxFit.cover) + : Center( + child: Text( + person.name.isNotEmpty ? person.name[0] : '?', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w600, + color: colors.onSurface.withValues(alpha: 0.3), + ), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + person.name, + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (person.occupation.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + person.occupation.join(' / '), + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + TextButton.icon( + onPressed: () { + Navigator.pop(context); + Navigator.push(context, MaterialPageRoute( + builder: (_) => PersonDetailPage(person: person), + )); + }, + icon: const Icon(Icons.arrow_outward, size: 16), + label: const Text('详情', style: TextStyle(fontSize: 13)), + style: TextButton.styleFrom( + foregroundColor: colors.primary, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: const Size(0, 0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ); + } + + Widget _buildSectionTitle(String title, ColorScheme colors) { + return Row( + children: [ + Container( + width: 4, + height: 14, + decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(width: 8), + Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), + ], + ); + } + + Widget _buildInfoRow(String label, String value, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 72, + child: Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ), + Expanded( + child: Text(value, style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.5)), + ), + ], + ), + ); + } + + Widget _buildSummary(String summary, ColorScheme colors) { + const int previewLimit = 80; + final needsToggle = summary.length > previewLimit; + final displayText = _summaryExpanded || !needsToggle + ? summary + : '${summary.substring(0, previewLimit)}…'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(displayText, style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.7)), + if (needsToggle) ...[ + const SizedBox(height: 4), + GestureDetector( + onTap: () => setState(() => _summaryExpanded = !_summaryExpanded), + child: Text( + _summaryExpanded ? '收起' : '展开', + style: TextStyle(fontSize: 12, color: colors.primary), + ), + ), + ], + ], + ); + } + + Widget _buildWorksSection(ColorScheme colors) { + final provider = context.read(); + + final movieGroups = >{}; + for (final mp in _moviePeople) { + movieGroups.putIfAbsent(mp.movieId, () => []).add(mp); + } + final bookGroups = >{}; + for (final bp in _bookPeople) { + bookGroups.putIfAbsent(bp.bookId, () => []).add(bp); + } + final gameGroups = >{}; + for (final gp in _gamePeople) { + gameGroups.putIfAbsent(gp.gameId, () => []).add(gp); + } + + final totalWorks = movieGroups.length + bookGroups.length + gameGroups.length; + if (totalWorks == 0) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Text('暂无关联作品', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))), + ), + ); + } + + String joinRoles(List roles) => roles.join(' / '); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (movieGroups.isNotEmpty) ...[ + Text('影视 (${movieGroups.length})', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 6), + ...movieGroups.entries.map((entry) { + final movie = provider.movies.where((m) => m.id == entry.key).firstOrNull; + if (movie == null) return const SizedBox.shrink(); + final seen = {}; + final roles = []; + for (final mp in entry.value) { + final key = '${mp.roleType}|${mp.characterName ?? ''}'; + if (!seen.add(key)) continue; + final label = _roleTypeLabel(mp.roleType); + if (mp.characterName != null && mp.characterName!.isNotEmpty) { + roles.add('$label 饰 ${mp.characterName}'); + } else { + roles.add(label); + } + } + return _buildWorkItem( + title: movie.title, + subtitle: joinRoles(roles), + posterPath: movie.posterPath, + colors: colors, + ); + }), + const SizedBox(height: 12), + ], + if (bookGroups.isNotEmpty) ...[ + Text('书籍 (${bookGroups.length})', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 6), + ...bookGroups.entries.map((entry) { + final book = provider.books.where((b) => b.id == entry.key).firstOrNull; + if (book == null) return const SizedBox.shrink(); + final seen = {}; + final roles = []; + for (final bp in entry.value) { + if (!seen.add(bp.roleType)) continue; + roles.add(_roleTypeLabel(bp.roleType)); + } + return _buildWorkItem( + title: book.title, + subtitle: joinRoles(roles), + posterPath: book.coverPath, + colors: colors, + ); + }), + const SizedBox(height: 12), + ], + if (gameGroups.isNotEmpty) ...[ + Text('游戏 (${gameGroups.length})', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 6), + ...gameGroups.entries.map((entry) { + final game = provider.games.where((g) => g.id == entry.key).firstOrNull; + if (game == null) return const SizedBox.shrink(); + final seen = {}; + final roles = []; + for (final gp in entry.value) { + if (!seen.add(gp.roleType)) continue; + roles.add(_roleTypeLabel(gp.roleType)); + } + return _buildWorkItem( + title: game.title, + subtitle: joinRoles(roles), + posterPath: game.coverPath, + colors: colors, + ); + }), + ], + ], + ); + } + + Widget _buildWorkItem({ + required String title, + required String subtitle, + String? posterPath, + required ColorScheme colors, + }) { + final hasPoster = posterPath != null && posterPath.isNotEmpty; + return Container( + margin: const EdgeInsets.symmetric(vertical: 3), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Container( + width: 32, + height: 44, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + clipBehavior: Clip.antiAlias, + child: hasPoster + ? FadeInLocalImage(path: posterPath, fit: BoxFit.cover) + : Center(child: Icon(Icons.movie_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2))), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), + maxLines: 1, overflow: TextOverflow.ellipsis), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(subtitle, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4)), + maxLines: 1, overflow: TextOverflow.ellipsis), + ], + ], + ), + ), + ], + ), + ); + } + + String _genderLabel(String gender) { + return switch (gender) { + 'male' => '男', + 'female' => '女', + 'other' => '其他', + _ => gender, + }; + } + + String _roleTypeLabel(String roleType) { + return switch (roleType) { + 'director' => '导演', + 'writer' => '编剧', + 'actor' => '演员', + 'author' => '作者', + 'translator' => '译者', + 'developer' => '开发者', + _ => roleType, + }; + } + + String _formatDate(DateTime date) { + return '${date.year}年${date.month.toString().padLeft(2, '0')}月${date.day.toString().padLeft(2, '0')}日'; + } +} diff --git a/lib/widgets/work_people_section.dart b/lib/widgets/work_people_section.dart new file mode 100644 index 0000000..ba2308d --- /dev/null +++ b/lib/widgets/work_people_section.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/data_models.dart'; +import '../providers/app_provider.dart'; +import 'fade_in_local_image.dart'; +import 'person_info_sheet.dart'; + +/// 作品详情页使用的"关联人物"区块 +/// 根据 workType + workId 加载关联的 Person 列表并展示 +class WorkPeopleSection extends StatefulWidget { + final String workId; + final String workType; // 'movie' / 'book' / 'game' + + const WorkPeopleSection({ + super.key, + required this.workId, + required this.workType, + }); + + @override + State createState() => _WorkPeopleSectionState(); +} + +class _WorkPeopleSectionState extends State { + List<_PersonRole> _items = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final provider = context.read(); + // 确保 people 已加载,否则 person 查找全部失败 + if (provider.people.isEmpty) { + await provider.loadPeople(); + } + final people = provider.people; + + // 按 personId 聚合:同一个人可能有多条关联(导演/编剧/演员等) + final Map byPerson = {}; + void addRelation(dynamic r, {bool withCharacter = false}) { + final personId = r.personId as String; + final roleType = r.roleType as String; + final person = people.where((p) => p.id == personId).firstOrNull; + if (person == null) return; + final existing = byPerson[personId]; + if (existing != null) { + existing.roleTypes.add(roleType); + if (withCharacter) { + final c = r.characterName as String?; + if (c != null && c.isNotEmpty) existing.characterNames.add(c); + } + } else { + final characterNames = []; + if (withCharacter) { + final c = r.characterName as String?; + if (c != null && c.isNotEmpty) characterNames.add(c); + } + byPerson[personId] = _PersonRole( + person: person, + roleTypes: [roleType], + characterNames: characterNames, + ); + } + } + + switch (widget.workType) { + case 'movie': + final rels = await provider.getMoviePeople(widget.workId); + for (final r in rels) { + addRelation(r, withCharacter: true); + } + break; + case 'book': + final rels = await provider.getBookPeople(widget.workId); + for (final r in rels) { + addRelation(r); + } + break; + case 'game': + final rels = await provider.getGamePeople(widget.workId); + for (final r in rels) { + addRelation(r); + } + break; + } + + // 按 sortOrder 保留首次出现的顺序 + final items = byPerson.values.toList(); + if (!mounted) return; + setState(() { + _items = items; + _loading = false; + }); + } + + String _roleLabel(String roleType) { + return switch (roleType) { + 'director' => '导演', + 'writer' => '编剧', + 'actor' => '演员', + 'author' => '作者', + 'translator' => '译者', + 'developer' => '开发者', + _ => roleType, + }; + } + + /// 角色排序权重:演员放最后 + int _roleWeight(String roleType) { + return switch (roleType) { + 'director' => 0, + 'writer' => 1, + 'author' => 2, + 'translator' => 3, + 'developer' => 4, + 'actor' => 99, + _ => 50, + }; + } + + /// 拼接角色描述: + /// 「导演 / 编剧」 + /// 「导演 / 演员 饰 唐僧 / 演员 饰 孙悟空」 + String _buildRoleText(_PersonRole item) { + final parts = []; + // 非演员角色:去重后按权重排序 + final nonActor = item.roleTypes.where((r) => r != 'actor').toSet().toList() + ..sort((a, b) => _roleWeight(a).compareTo(_roleWeight(b))); + parts.addAll(nonActor.map(_roleLabel)); + + // 演员角色:每个饰演角色名单独成段 + final isActor = item.roleTypes.contains('actor'); + if (isActor) { + if (item.characterNames.isEmpty) { + parts.add('演员'); + } else { + for (final c in item.characterNames) { + parts.add('演员 饰 $c'); + } + } + } + return parts.join(' / '); + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const SizedBox.shrink(); + } + if (_items.isEmpty) return const SizedBox.shrink(); + + final colors = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: colors.onSurface, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + '人物', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + ], + ), + const SizedBox(height: 20), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _items.map((item) { + final idx = _items.indexOf(item); + return Padding( + padding: EdgeInsets.only(left: idx == 0 ? 0 : 16), + child: _buildPersonChip(item, colors), + ); + }).toList(), + ), + ), + ], + ), + ); + } + + Widget _buildPersonChip(_PersonRole item, ColorScheme colors) { + final person = item.person; + final hasPhoto = person.photoPath != null && person.photoPath!.isNotEmpty; + return GestureDetector( + onTap: () => PersonInfoSheet.show(context, person), + child: SizedBox( + width: 84, + child: Column( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + child: hasPhoto + ? FadeInLocalImage(path: person.photoPath, fit: BoxFit.cover) + : Center( + child: Text( + person.name.isNotEmpty ? person.name[0] : '?', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: colors.onSurface.withValues(alpha: 0.3), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + person.name, + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + _buildRoleText(item), + style: TextStyle( + fontSize: 10, + color: colors.onSurface.withValues(alpha: 0.4), + height: 1.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +class _PersonRole { + final Person person; + final List roleTypes; + final List characterNames; + + _PersonRole({ + required this.person, + required this.roleTypes, + required this.characterNames, + }); +} diff --git a/lib/widgets/work_selector_page.dart b/lib/widgets/work_selector_page.dart new file mode 100644 index 0000000..f88c510 --- /dev/null +++ b/lib/widgets/work_selector_page.dart @@ -0,0 +1,737 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; +import '../models/data_models.dart'; +import '../providers/app_provider.dart'; +import 'fade_in_local_image.dart'; + +/// 人物详情页使用的作品关联结果(按媒体类型分组) +class WorkSelectionResult { + final List movies; + final List books; + final List games; + + WorkSelectionResult({required this.movies, required this.books, required this.games}); +} + +/// 一条"作品 + 角色"选择(内部用) +class _WorkRoleEntry { + final String workId; // movieId / bookId / gameId + final String workType; // 'movie' / 'book' / 'game' + final String roleType; + final String? characterName; // 仅影视 actor + + _WorkRoleEntry({ + required this.workId, + required this.workType, + required this.roleType, + this.characterName, + }); +} + +/// 作品选择独立页面(人物详情页使用) +/// 选择影视/书籍/游戏作品并为每个作品分配 1~N 个角色 +/// 支持按作品标题或人物名称搜索 +class WorkSelectorPage extends StatefulWidget { + final String personId; + final List initialMovies; + final List initialBooks; + final List initialGames; + + const WorkSelectorPage({ + super.key, + required this.personId, + required this.initialMovies, + required this.initialBooks, + required this.initialGames, + }); + + static Future show({ + required BuildContext context, + required String personId, + required List initialMovies, + required List initialBooks, + required List initialGames, + }) { + return Navigator.push( + context, + MaterialPageRoute( + builder: (_) => WorkSelectorPage( + personId: personId, + initialMovies: initialMovies, + initialBooks: initialBooks, + initialGames: initialGames, + ), + ), + ); + } + + @override + State createState() => _WorkSelectorPageState(); +} + +class _WorkSelectorPageState extends State { + /// 所有已选的"作品+角色"条目(影视/书籍/游戏混在一起,靠 workType 区分) + final List<_WorkRoleEntry> _entries = []; + + /// 当前 Tab:0=影视 1=书籍 2=游戏 + int _tabIndex = 0; + String _query = ''; + + /// 搜索模式:0=按作品标题 1=按人物名称 + int _searchMode = 0; + + /// 人物名称搜索结果:personId → 该人物在当前 Tab 作品类型下的关联作品 ID 集合 + List _matchedPeople = []; + bool _searching = false; + + static const _movieRoles = [('director', '导演'), ('writer', '编剧'), ('actor', '演员')]; + static const _bookRoles = [('author', '作者'), ('translator', '译者')]; + static const _gameRoles = [('developer', '开发者')]; + + @override + void initState() { + super.initState(); + for (final mp in widget.initialMovies) { + _entries.add(_WorkRoleEntry(workId: mp.movieId, workType: 'movie', roleType: mp.roleType, characterName: mp.characterName)); + } + for (final bp in widget.initialBooks) { + _entries.add(_WorkRoleEntry(workId: bp.bookId, workType: 'book', roleType: bp.roleType)); + } + for (final gp in widget.initialGames) { + _entries.add(_WorkRoleEntry(workId: gp.gameId, workType: 'game', roleType: gp.roleType)); + } + } + + String get _currentWorkType => switch (_tabIndex) { 0 => 'movie', 1 => 'book', 2 => 'game', _ => 'movie' }; + List<(String, String)> get _currentRoleOptions => switch (_tabIndex) { 0 => _movieRoles, 1 => _bookRoles, 2 => _gameRoles, _ => _movieRoles }; + + /// 按人物名称搜索:找到匹配的人物,再反查他们参与的当前 Tab 类型作品 + Future _searchByPerson(String keyword) async { + final trimmed = keyword.trim(); + if (trimmed.isEmpty) { + setState(() { + _matchedPeople = []; + _searching = false; + }); + return; + } + setState(() => _searching = true); + final provider = context.read(); + final people = await provider.searchPeople(trimmed); + if (!mounted) return; + setState(() { + _matchedPeople = people; + _searching = false; + }); + } + + /// 获取人物在当前 Tab 类型下的作品 ID 集合 + Future> _getPersonWorkIds(Person person) async { + final provider = context.read(); + switch (_currentWorkType) { + case 'movie': + final rels = await provider.getPersonMovies(person.id); + return rels.map((r) => r.movieId).toSet(); + case 'book': + final rels = await provider.getPersonBooks(person.id); + return rels.map((r) => r.bookId).toSet(); + case 'game': + final rels = await provider.getPersonGames(person.id); + return rels.map((r) => r.gameId).toSet(); + } + return {}; + } + + void _changeRole(_WorkRoleEntry entry, String roleType) { + setState(() { + final idx = _entries.indexOf(entry); + if (idx < 0) return; + _entries[idx] = _WorkRoleEntry( + workId: entry.workId, + workType: entry.workType, + roleType: roleType, + characterName: entry.workType == 'movie' && roleType == 'actor' ? entry.characterName : null, + ); + }); + } + + void _editCharacterName(_WorkRoleEntry entry, String name) { + setState(() { + final idx = _entries.indexOf(entry); + if (idx < 0) return; + _entries[idx] = _WorkRoleEntry( + workId: entry.workId, + workType: entry.workType, + roleType: entry.roleType, + characterName: name.isEmpty ? null : name, + ); + }); + } + + void _removeEntry(_WorkRoleEntry entry) { + setState(() => _entries.remove(entry)); + } + + /// 为已选作品追加一个新角色条目(多角色) + void _addRoleToWork(String workId) { + final usedRoles = _entries + .where((e) => e.workType == _currentWorkType && e.workId == workId) + .map((e) => e.roleType) + .toSet(); + final nextRole = _currentRoleOptions.firstWhere((r) => !usedRoles.contains(r.$1), orElse: () => _currentRoleOptions.first); + setState(() { + _entries.add(_WorkRoleEntry( + workId: workId, + workType: _currentWorkType, + roleType: nextRole.$1, + )); + }); + } + + void _onConfirm() { + final movies = _entries + .where((e) => e.workType == 'movie') + .map((e) => MoviePerson( + id: const Uuid().v4(), + movieId: e.workId, + personId: widget.personId, + roleType: e.roleType, + characterName: e.characterName, + sortOrder: 0, + )) + .toList(); + final books = _entries + .where((e) => e.workType == 'book') + .map((e) => BookPerson( + id: const Uuid().v4(), + bookId: e.workId, + personId: widget.personId, + roleType: e.roleType, + sortOrder: 0, + )) + .toList(); + final games = _entries + .where((e) => e.workType == 'game') + .map((e) => GamePerson( + id: const Uuid().v4(), + gameId: e.workId, + personId: widget.personId, + roleType: e.roleType, + sortOrder: 0, + )) + .toList(); + Navigator.pop(context, WorkSelectionResult(movies: movies, books: books, games: games)); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final provider = context.watch(); + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: const Text('关联作品'), + actions: [ + TextButton( + onPressed: _onConfirm, + child: Text('完成', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.primary)), + ), + ], + ), + body: Column( + children: [ + // Tab 切换 + _buildTabs(colors), + // 搜索模式切换 + _buildSearchModeToggle(colors), + // 搜索框 + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: TextField( + style: TextStyle(fontSize: 14, color: colors.onSurface), + cursorColor: colors.primary, + decoration: InputDecoration( + hintText: _searchMode == 0 ? '搜索作品标题' : '搜索人物名称', + hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), + filled: true, + fillColor: colors.surfaceContainerHigh, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)), + prefixIcon: Icon(Icons.search, size: 18, color: colors.onSurface.withValues(alpha: 0.3)), + suffixIcon: _query.isNotEmpty + ? IconButton( + icon: Icon(Icons.close, size: 18, color: colors.onSurface.withValues(alpha: 0.4)), + onPressed: () { + setState(() { + _query = ''; + _matchedPeople = []; + }); + }, + ) + : null, + ), + onChanged: (v) { + setState(() => _query = v); + if (_searchMode == 1) { + _searchByPerson(v); + } + }, + ), + ), + // 可选作品列表 + Expanded(child: _searchMode == 0 ? _buildAvailableList(provider, colors) : _buildPersonSearchList(provider, colors)), + ], + ), + ); + } + + Widget _buildTabs(ColorScheme colors) { + const labels = ['影视', '书籍', '游戏']; + return Container( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: List.generate(labels.length, (i) { + final selected = _tabIndex == i; + return Expanded( + child: GestureDetector( + onTap: () => setState(() { _tabIndex = i; _query = ''; _matchedPeople = []; }), + child: Container( + margin: EdgeInsets.only(right: i < 2 ? 8 : 0), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: selected ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + labels[i], + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5), + ), + ), + ), + ), + ); + }), + ), + ); + } + + Widget _buildSearchModeToggle(ColorScheme colors) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + children: [ + _buildModeChip(0, '按作品', colors), + const SizedBox(width: 8), + _buildModeChip(1, '按人物', colors), + ], + ), + ); + } + + Widget _buildModeChip(int mode, String label, ColorScheme colors) { + final selected = _searchMode == mode; + return GestureDetector( + onTap: () => setState(() { + _searchMode = mode; + _query = ''; + _matchedPeople = []; + }), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w500 : FontWeight.normal, + color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5), + ), + ), + ), + ); + } + + /// 按人物名称搜索结果列表 + Widget _buildPersonSearchList(AppProvider provider, ColorScheme colors) { + if (_query.trim().isEmpty) { + return Center( + child: Text('输入人物名称搜索作品', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))), + ); + } + if (_searching) { + return Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.onSurface.withValues(alpha: 0.3))); + } + if (_matchedPeople.isEmpty) { + return Center( + child: Text('未找到匹配的人物', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))), + ); + } + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + itemCount: _matchedPeople.length, + itemBuilder: (_, i) => _buildPersonItem(_matchedPeople[i], provider, colors), + ); + } + + Widget _buildPersonItem(Person person, AppProvider provider, ColorScheme colors) { + return FutureBuilder>( + future: _getPersonWorkIds(person), + builder: (ctx, snapshot) { + final workIds = snapshot.data ?? {}; + final works = _getWorksByIds(workIds, provider); + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 人物名 + Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration(color: colors.surface, shape: BoxShape.circle), + clipBehavior: Clip.antiAlias, + child: person.photoPath != null && person.photoPath!.isNotEmpty + ? FadeInLocalImage(path: person.photoPath, fit: BoxFit.cover) + : Center( + child: Text( + person.name.isNotEmpty ? person.name[0] : '?', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4)), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(person.name, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), + if (person.occupation.isNotEmpty) + Text(person.occupation.join(' / '), + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ), + if (works.isEmpty) + Text('暂无该类型作品', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + ], + ), + // 该人物在当前 Tab 下的作品列表 + ...works.map((work) => _buildPersonWorkItem(work, person, colors)), + ], + ), + ); + }, + ); + } + + /// 根据 ID 集合获取当前 Tab 类型的作品列表 (id, title, coverPath) + List<(String, String, String?)> _getWorksByIds(Set ids, AppProvider provider) { + switch (_currentWorkType) { + case 'movie': + return provider.movies + .where((m) => !m.isDeleted && ids.contains(m.id)) + .map((m) => (m.id, m.title, m.posterPath)) + .toList(); + case 'book': + return provider.books + .where((b) => !b.isDeleted && ids.contains(b.id)) + .map((b) => (b.id, b.title, b.coverPath)) + .toList(); + case 'game': + return provider.games + .where((g) => !g.isDeleted && ids.contains(g.id)) + .map((g) => (g.id, g.title, g.coverPath)) + .toList(); + } + return []; + } + + Widget _buildPersonWorkItem((String, String, String?) work, Person person, ColorScheme colors) { + final id = work.$1; + final title = work.$2; + final coverPath = work.$3; + final workEntries = _entries.where((e) => e.workType == _currentWorkType && e.workId == id).toList(); + final canAddMore = workEntries.length < _currentRoleOptions.length; + + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _buildCover(coverPath, 32, colors), + const SizedBox(width: 8), + Expanded( + child: Text(title, style: TextStyle(fontSize: 13, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis), + ), + if (canAddMore) + GestureDetector( + onTap: () => _addRoleToWork(id), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: colors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: 14, color: colors.primary), + const SizedBox(width: 2), + Text('角色', style: TextStyle(fontSize: 11, color: colors.primary)), + ], + ), + ), + ) + else + Icon(Icons.check_circle, size: 16, color: colors.onSurface.withValues(alpha: 0.3)), + ], + ), + ...workEntries.map((entry) => _buildRoleRow(entry, colors)), + ], + ), + ), + ); + } + + Widget _buildRoleDropdown(_WorkRoleEntry entry, ColorScheme colors) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + child: DropdownButton( + value: entry.roleType, + underline: const SizedBox.shrink(), + isDense: true, + style: TextStyle(fontSize: 12, color: colors.onSurface), + items: _roleOptionsFor(entry.workType) + .map((r) => DropdownMenuItem(value: r.$1, child: Text(r.$2, style: const TextStyle(fontSize: 12)))) + .toList(), + onChanged: (v) { if (v != null) _changeRole(entry, v); }, + ), + ); + } + + List<(String, String)> _roleOptionsFor(String workType) { + return switch (workType) { 'movie' => _movieRoles, 'book' => _bookRoles, 'game' => _gameRoles, _ => _movieRoles }; + } + + Widget _buildCover(String? path, double size, ColorScheme colors) { + final has = path != null && path.isNotEmpty; + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + width: size * 0.72, + height: size, + child: has + ? FadeInLocalImage(path: path, fit: BoxFit.cover) + : Container(color: colors.surfaceContainerHighest, child: Icon(Icons.movie_outlined, size: 12, color: colors.onSurface.withValues(alpha: 0.2))), + ), + ); + } + + void _showCharacterNameDialog(_WorkRoleEntry entry, String? current) { + final ctrl = TextEditingController(text: current ?? ''); + showDialog( + context: context, + builder: (ctx) { + final colors = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('饰演角色', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: TextField( + controller: ctrl, + autofocus: true, + style: TextStyle(fontSize: 15, color: colors.onSurface), + cursorColor: colors.primary, + decoration: InputDecoration( + hintText: '如:关羽', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)), + filled: true, + fillColor: colors.surfaceContainerHigh, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)), + ), + onSubmitted: (v) { Navigator.pop(ctx); _editCharacterName(entry, v.trim()); }, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () { Navigator.pop(ctx); _editCharacterName(entry, ctrl.text.trim()); }, + style: ElevatedButton.styleFrom(backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Text('确定'), + ), + ], + ); + }, + ); + } + + Widget _buildAvailableList(AppProvider provider, ColorScheme colors) { + final query = _query.toLowerCase(); + final workType = _currentWorkType; + + List<(String id, String title, String? coverPath)> works; + switch (workType) { + case 'movie': + works = provider.movies + .where((m) => !m.isDeleted && (query.isEmpty || m.title.toLowerCase().contains(query))) + .map((m) => (m.id, m.title, m.posterPath)) + .toList(); + break; + case 'book': + works = provider.books + .where((b) => !b.isDeleted && (query.isEmpty || b.title.toLowerCase().contains(query))) + .map((b) => (b.id, b.title, b.coverPath)) + .toList(); + break; + case 'game': + works = provider.games + .where((g) => !g.isDeleted && (query.isEmpty || g.title.toLowerCase().contains(query))) + .map((g) => (g.id, g.title, g.coverPath)) + .toList(); + break; + default: + works = []; + } + + if (works.isEmpty) { + return Center( + child: Text( + query.isEmpty ? '暂无可选作品' : '无匹配结果', + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + itemCount: works.length, + itemBuilder: (_, i) => _buildAvailableItem(works[i], colors), + ); + } + + Widget _buildAvailableItem((String id, String title, String? coverPath) work, ColorScheme colors) { + final id = work.$1; + final title = work.$2; + final coverPath = work.$3; + final workEntries = _entries.where((e) => e.workType == _currentWorkType && e.workId == id).toList(); + final canAddMore = workEntries.length < _currentRoleOptions.length; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 作品标题行 + Row( + children: [ + _buildCover(coverPath, 36, colors), + const SizedBox(width: 10), + Expanded( + child: Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis), + ), + if (canAddMore) + GestureDetector( + onTap: () => _addRoleToWork(id), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: colors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: 14, color: colors.primary), + const SizedBox(width: 2), + Text('角色', style: TextStyle(fontSize: 11, color: colors.primary)), + ], + ), + ), + ) + else + Icon(Icons.check_circle, size: 18, color: colors.onSurface.withValues(alpha: 0.3)), + ], + ), + // 已选角色行 + ...workEntries.map((entry) => _buildRoleRow(entry, colors)), + ], + ), + ); + } + + /// 角色行:职业下拉 + 角色名(影视演员)+ 编辑 + 删除 + Widget _buildRoleRow(_WorkRoleEntry entry, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.only(top: 6, left: 46), + child: Row( + children: [ + // 职业标签/下拉 + _buildRoleDropdown(entry, colors), + const SizedBox(width: 8), + // 角色名(仅影视演员) + if (entry.workType == 'movie' && entry.roleType == 'actor') ...[ + GestureDetector( + onTap: () => _showCharacterNameDialog(entry, entry.characterName), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(6), border: Border.all(color: colors.outlineVariant, width: 0.5)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + entry.characterName != null && entry.characterName!.isNotEmpty ? '饰 ${entry.characterName}' : '设置角色', + style: TextStyle(fontSize: 12, color: entry.characterName != null ? colors.onSurface : colors.onSurface.withValues(alpha: 0.3)), + ), + const SizedBox(width: 4), + Icon(Icons.edit, size: 12, color: colors.onSurface.withValues(alpha: 0.4)), + ], + ), + ), + ), + const SizedBox(width: 4), + ], + const Spacer(), + // 删除 + GestureDetector( + onTap: () => _removeEntry(entry), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), + ), + ), + ], + ), + ); + } +}