generated from dellevin/template
优化新增人物功能
This commit is contained in:
@@ -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<void> _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<void> _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)');
|
||||
}
|
||||
|
||||
// 关闭数据库
|
||||
|
||||
87
lib/data/person/book_person_dao.dart
Normal file
87
lib/data/person/book_person_dao.dart
Normal file
@@ -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<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
debugPrint('[BookPersonDao] $op error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取某本书的所有关联人物
|
||||
Future<List<BookPerson>> 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<List<BookPerson>> 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<int> insert(BookPerson bookPerson) => _wrap('insert', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.insert('book_people', bookPerson.toJson());
|
||||
});
|
||||
|
||||
// 批量添加关联
|
||||
Future<void> insertAll(List<BookPerson> 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<int> deleteByBookId(String bookId) => _wrap('deleteByBookId', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.delete('book_people', where: 'book_id = ?', whereArgs: [bookId]);
|
||||
});
|
||||
|
||||
// 删除某个人物的所有书籍关联
|
||||
Future<int> deleteByPersonId(String personId) => _wrap('deleteByPersonId', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.delete('book_people', where: 'person_id = ?', whereArgs: [personId]);
|
||||
});
|
||||
|
||||
// 删除单条关联
|
||||
Future<int> deleteById(String id) => _wrap('deleteById', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.delete('book_people', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
|
||||
// 检查关联是否已存在(避免重复插入)
|
||||
Future<bool> 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;
|
||||
});
|
||||
}
|
||||
87
lib/data/person/game_person_dao.dart
Normal file
87
lib/data/person/game_person_dao.dart
Normal file
@@ -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<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
debugPrint('[GamePersonDao] $op error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取某游戏的所有关联人物
|
||||
Future<List<GamePerson>> 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<List<GamePerson>> 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<int> insert(GamePerson gamePerson) => _wrap('insert', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.insert('game_people', gamePerson.toJson());
|
||||
});
|
||||
|
||||
// 批量添加关联
|
||||
Future<void> insertAll(List<GamePerson> 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<int> deleteByGameId(String gameId) => _wrap('deleteByGameId', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.delete('game_people', where: 'game_id = ?', whereArgs: [gameId]);
|
||||
});
|
||||
|
||||
// 删除某个人物的所有游戏关联
|
||||
Future<int> deleteByPersonId(String personId) => _wrap('deleteByPersonId', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.delete('game_people', where: 'person_id = ?', whereArgs: [personId]);
|
||||
});
|
||||
|
||||
// 删除单条关联
|
||||
Future<int> deleteById(String id) => _wrap('deleteById', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.delete('game_people', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
|
||||
// 检查关联是否已存在(避免重复插入)
|
||||
Future<bool> 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;
|
||||
});
|
||||
}
|
||||
110
lib/data/person/movie_person_dao.dart
Normal file
110
lib/data/person/movie_person_dao.dart
Normal file
@@ -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<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
debugPrint('[MoviePersonDao] $op error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取某部影视的所有关联人物
|
||||
Future<List<MoviePerson>> 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<List<MoviePerson>> 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<List<MoviePerson>> 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<int> insert(MoviePerson moviePerson) => _wrap('insert', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.insert('movie_people', moviePerson.toJson());
|
||||
});
|
||||
|
||||
// 批量添加关联
|
||||
Future<void> insertAll(List<MoviePerson> 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<int> 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<int> deleteByMovieId(String movieId) => _wrap('deleteByMovieId', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.delete('movie_people', where: 'movie_id = ?', whereArgs: [movieId]);
|
||||
});
|
||||
|
||||
// 删除某个人物的所有影视关联
|
||||
Future<int> deleteByPersonId(String personId) => _wrap('deleteByPersonId', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.delete('movie_people', where: 'person_id = ?', whereArgs: [personId]);
|
||||
});
|
||||
|
||||
// 删除单条关联
|
||||
Future<int> deleteById(String id) => _wrap('deleteById', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.delete('movie_people', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
|
||||
// 检查关联是否已存在(避免重复插入)
|
||||
Future<bool> 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;
|
||||
});
|
||||
}
|
||||
313
lib/data/person/person_dao.dart
Normal file
313
lib/data/person/person_dao.dart
Normal file
@@ -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<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
debugPrint('[PersonDao] $op error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有人物(未删除的)
|
||||
Future<List<Person>> 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<List<Person>> 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<List<Person>> 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<Person?> 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<Person?> 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<int> insertPerson(Person person) => _wrap('insertPerson', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.insert('people', person.toJson());
|
||||
});
|
||||
|
||||
// 更新人物
|
||||
Future<int> updatePerson(Person person) => _wrap('updatePerson', () async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.update(
|
||||
'people',
|
||||
person.toJson(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [person.id],
|
||||
);
|
||||
});
|
||||
|
||||
// 仅更新封面偏移量
|
||||
Future<void> 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<int> 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<int> 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<List<Person>> 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<int> 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<int> mergeDuplicatePeople() => _wrap('mergeDuplicatePeople', () async {
|
||||
final db = await _dbHelper.database;
|
||||
// 取所有人物(含已删除),按 name 分组
|
||||
final maps = await db.query('people', orderBy: 'updated_at DESC');
|
||||
final byName = <String, List<Map<String, Object?>>>{};
|
||||
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<String, Object?>.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<String, Object?> 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<String, Object?> merged, Map<String, Object?> 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 = <String>{};
|
||||
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<void> _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 = <String>{};
|
||||
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<List<String>> getAllPersonNames() => _wrap('getAllPersonNames', () async {
|
||||
final people = await getAllPeople();
|
||||
final names = <String>{};
|
||||
for (final p in people) {
|
||||
names.add(p.name);
|
||||
names.addAll(p.alternateNames);
|
||||
}
|
||||
return names.toList()..sort();
|
||||
});
|
||||
}
|
||||
@@ -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<String> alternateNames; // 其他名称
|
||||
final List<String> 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<String, dynamic> 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<String, dynamic> 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<String>? alternateNames,
|
||||
List<String>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'game_id': gameId,
|
||||
'person_id': personId,
|
||||
'role_type': roleType,
|
||||
'sort_order': sortOrder,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<BookDetailPage> {
|
||||
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<BookDetailPage> {
|
||||
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<BookDetailPage> {
|
||||
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),
|
||||
],
|
||||
|
||||
@@ -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<GameDetailPage> {
|
||||
_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<GameDetailPage> {
|
||||
_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<GameDetailPage> {
|
||||
_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),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -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<MovieDetailPage> {
|
||||
)),
|
||||
]),
|
||||
],
|
||||
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<MovieDetailPage> {
|
||||
_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<MovieDetailPage> {
|
||||
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),
|
||||
|
||||
500
lib/pages/people/person_detail_page.dart
Normal file
500
lib/pages/people/person_detail_page.dart
Normal file
@@ -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<PersonDetailPage> createState() => _PersonDetailPageState();
|
||||
}
|
||||
|
||||
class _PersonDetailPageState extends State<PersonDetailPage> {
|
||||
List<MoviePerson> _moviePeople = [];
|
||||
List<BookPerson> _bookPeople = [];
|
||||
List<GamePerson> _gamePeople = [];
|
||||
bool _loading = true;
|
||||
bool _summaryExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRelations();
|
||||
}
|
||||
|
||||
Future<void> _loadRelations() async {
|
||||
final provider = context.read<AppProvider>();
|
||||
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<MoviePerson>;
|
||||
_bookPeople = results[1] as List<BookPerson>;
|
||||
_gamePeople = results[2] as List<GamePerson>;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final person = context.watch<AppProvider>().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<AppProvider>();
|
||||
|
||||
// 按作品 ID 分组,合并多角色
|
||||
final movieGroups = <String, List<MoviePerson>>{};
|
||||
for (final mp in _moviePeople) {
|
||||
movieGroups.putIfAbsent(mp.movieId, () => []).add(mp);
|
||||
}
|
||||
final bookGroups = <String, List<BookPerson>>{};
|
||||
for (final bp in _bookPeople) {
|
||||
bookGroups.putIfAbsent(bp.bookId, () => []).add(bp);
|
||||
}
|
||||
final gameGroups = <String, List<GamePerson>>{};
|
||||
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<String> 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 = <String>{};
|
||||
final roles = <String>[];
|
||||
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 = <String>{};
|
||||
final roles = <String>[];
|
||||
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 = <String>{};
|
||||
final roles = <String>[];
|
||||
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<AppProvider>().loadPeople();
|
||||
_loadRelations();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<AppProvider>();
|
||||
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<AppProvider>();
|
||||
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('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
602
lib/pages/people/person_form_page.dart
Normal file
602
lib/pages/people/person_form_page.dart
Normal file
@@ -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<PersonFormPage> createState() => _PersonFormPageState();
|
||||
}
|
||||
|
||||
class _PersonFormPageState extends State<PersonFormPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _summaryCtrl = TextEditingController();
|
||||
final _birthPlaceCtrl = TextEditingController();
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
String? _gender;
|
||||
DateTime? _birthDate;
|
||||
String? _birthPlace;
|
||||
List<String> _alternateNames = [];
|
||||
List<String> _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<void> _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<void> _pickPhotoFromUrl() async {
|
||||
String? url;
|
||||
final confirmed = await showDialog<bool>(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<void> _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<String>? 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<DateTime?> 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<String> 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<bool> _confirmLeave() async {
|
||||
if (!_hasContent()) return true;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final result = await showDialog<bool>(
|
||||
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<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final provider = context.read<AppProvider>();
|
||||
|
||||
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<String?> _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;
|
||||
}
|
||||
}
|
||||
375
lib/pages/people/person_list_page.dart
Normal file
375
lib/pages/people/person_list_page.dart
Normal file
@@ -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<PersonListPage> createState() => _PersonListPageState();
|
||||
}
|
||||
|
||||
class _PersonListPageState extends State<PersonListPage> {
|
||||
String _searchKeyword = '';
|
||||
String? _occupationFilter; // 职业筛选
|
||||
final TextEditingController _searchCtrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Person> _filterPeople(List<Person> 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<AppProvider>().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<Person> people) {
|
||||
// 收集所有职业及其数量
|
||||
final occupationCounts = <String, int>{};
|
||||
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<void> _refreshRelations() async {
|
||||
final provider = context.read<AppProvider>();
|
||||
// 显示加载弹窗
|
||||
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 = <String>[];
|
||||
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<AppProvider>().loadPeople();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -292,7 +292,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
_buildSwitchItem(
|
||||
Icons.people_outline, '角色信息', '管理影视和书籍中的角色', _showPerson,
|
||||
Icons.people_outline, '人物', '管理影视、书籍和游戏中的人物', _showPerson,
|
||||
(v) async {
|
||||
await _userPrefs.setShowSidebarPerson(v);
|
||||
setState(() => _showPerson = v);
|
||||
|
||||
@@ -12,7 +12,7 @@ class RecycleBinPage extends StatefulWidget {
|
||||
State<RecycleBinPage> 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<RecycleBinPage> {
|
||||
@@ -113,6 +121,7 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
||||
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<RecycleBinPage> {
|
||||
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<RecycleBinPage> {
|
||||
_filterChip('书评', _ItemType.bookReview),
|
||||
_filterChip('书摘', _ItemType.bookExcerpt),
|
||||
_filterChip('游戏评价', _ItemType.gameReview),
|
||||
_filterChip('人物', _ItemType.person),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -428,6 +439,9 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
||||
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<RecycleBinPage> {
|
||||
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, '已彻底删除');
|
||||
|
||||
@@ -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<Movie> _movies = [];
|
||||
List<Book> _books = [];
|
||||
List<Note> _notes = [];
|
||||
List<Game> _games = [];
|
||||
List<Playlist> _playlists = [];
|
||||
List<Person> _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<void> loadPeople() async {
|
||||
_people = await _personDao.getAllPeople();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 编辑返回后触发列表页重载
|
||||
/// [itemId] 被编辑条目的 ID,用于就地更新而非重置分页
|
||||
void setEditRefresh([String? itemId]) {
|
||||
@@ -295,6 +316,7 @@ class AppProvider extends ChangeNotifier {
|
||||
List<Note> get notes => UnmodifiableListView(_notes);
|
||||
List<Game> get games => UnmodifiableListView(_games);
|
||||
List<Playlist> get playlists => UnmodifiableListView(_playlists);
|
||||
List<Person> get people => UnmodifiableListView(_people);
|
||||
|
||||
// 根据状态获取影视列表
|
||||
List<Movie> 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<void> addPerson(Person person) async {
|
||||
await _personDao.insertPerson(person);
|
||||
await loadPeople();
|
||||
}
|
||||
|
||||
/// 更新人物
|
||||
Future<void> updatePerson(Person person) async {
|
||||
await _personDao.updatePerson(person);
|
||||
await loadPeople();
|
||||
}
|
||||
|
||||
/// 更新人物封面偏移量
|
||||
Future<void> updatePersonCoverOffset(String personId, double offset) async {
|
||||
await _personDao.updateCoverOffset(personId, offset);
|
||||
}
|
||||
|
||||
/// 软删除人物
|
||||
Future<void> removePerson(String id) async {
|
||||
await _personDao.deletePerson(id);
|
||||
await loadPeople();
|
||||
}
|
||||
|
||||
/// 恢复已删除的人物
|
||||
Future<void> restorePerson(String id) async {
|
||||
await _personDao.restorePerson(id);
|
||||
await loadPeople();
|
||||
}
|
||||
|
||||
/// 彻底删除人物
|
||||
Future<void> permanentDeletePerson(String id) async {
|
||||
await ImagePathHelper.instance.deletePersonImages(id);
|
||||
await _personDao.permanentDeletePerson(id);
|
||||
}
|
||||
|
||||
/// 获取已删除的人物
|
||||
Future<List<Person>> getDeletedPeople() async {
|
||||
return await _personDao.getDeletedPeople();
|
||||
}
|
||||
|
||||
/// 搜索人物
|
||||
Future<List<Person>> searchPeople(String keyword) async {
|
||||
return await _personDao.searchPeople(keyword);
|
||||
}
|
||||
|
||||
/// 根据ID获取人物
|
||||
Future<Person?> getPersonById(String id) async {
|
||||
return await _personDao.getPersonById(id);
|
||||
}
|
||||
|
||||
// ========== 人物关联查询 ==========
|
||||
|
||||
/// 获取某部影视的关联人物
|
||||
Future<List<MoviePerson>> getMoviePeople(String movieId) async {
|
||||
return await _moviePersonDao.getByMovieId(movieId);
|
||||
}
|
||||
|
||||
/// 获取某个人物参与的影视
|
||||
Future<List<MoviePerson>> getPersonMovies(String personId) async {
|
||||
return await _moviePersonDao.getByPersonId(personId);
|
||||
}
|
||||
|
||||
/// 获取某本书的关联人物
|
||||
Future<List<BookPerson>> getBookPeople(String bookId) async {
|
||||
return await _bookPersonDao.getByBookId(bookId);
|
||||
}
|
||||
|
||||
/// 获取某个人物参与的书籍
|
||||
Future<List<BookPerson>> getPersonBooks(String personId) async {
|
||||
return await _bookPersonDao.getByPersonId(personId);
|
||||
}
|
||||
|
||||
/// 获取某游戏的关联人物
|
||||
Future<List<GamePerson>> getGamePeople(String gameId) async {
|
||||
return await _gamePersonDao.getByGameId(gameId);
|
||||
}
|
||||
|
||||
/// 获取某个人物参与的游戏
|
||||
Future<List<GamePerson>> getPersonGames(String personId) async {
|
||||
return await _gamePersonDao.getByPersonId(personId);
|
||||
}
|
||||
|
||||
/// 保存影视的人物关联(先删后插)
|
||||
Future<void> saveMoviePeople(String movieId, List<MoviePerson> people) async {
|
||||
await _moviePersonDao.deleteByMovieId(movieId);
|
||||
if (people.isNotEmpty) {
|
||||
await _moviePersonDao.insertAll(people);
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存书籍的人物关联
|
||||
Future<void> saveBookPeople(String bookId, List<BookPerson> people) async {
|
||||
await _bookPersonDao.deleteByBookId(bookId);
|
||||
if (people.isNotEmpty) {
|
||||
await _bookPersonDao.insertAll(people);
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存游戏的人物关联
|
||||
Future<void> saveGamePeople(String gameId, List<GamePerson> people) async {
|
||||
await _gamePersonDao.deleteByGameId(gameId);
|
||||
if (people.isNotEmpty) {
|
||||
await _gamePersonDao.insertAll(people);
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存某个人物的所有影视关联(先删后插,按 personId 维度)
|
||||
Future<void> savePersonMovieRelations(String personId, List<MoviePerson> relations) async {
|
||||
await _moviePersonDao.deleteByPersonId(personId);
|
||||
if (relations.isNotEmpty) {
|
||||
await _moviePersonDao.insertAll(relations);
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存某个人物的所有书籍关联
|
||||
Future<void> savePersonBookRelations(String personId, List<BookPerson> relations) async {
|
||||
await _bookPersonDao.deleteByPersonId(personId);
|
||||
if (relations.isNotEmpty) {
|
||||
await _bookPersonDao.insertAll(relations);
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存某个人物的所有游戏关联
|
||||
Future<void> savePersonGameRelations(String personId, List<GamePerson> 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 = <String, Person>{};
|
||||
for (final p in _people) {
|
||||
nameIndex[p.name] = p;
|
||||
}
|
||||
|
||||
int newPersons = 0;
|
||||
int newRelations = 0;
|
||||
const uuid = Uuid();
|
||||
final now = DateTime.now();
|
||||
|
||||
Future<Person> 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, String)>[
|
||||
(movie.directors, 'director', '导演'),
|
||||
(movie.writers, 'writer', '编剧'),
|
||||
(movie.actors, 'actor', '演员'),
|
||||
];
|
||||
for (final (names, roleType, occupationLabel) in entries) {
|
||||
final seen = <String>{};
|
||||
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, String)>[
|
||||
(book.authors, 'author', '作者'),
|
||||
(book.translators, 'translator', '译者'),
|
||||
];
|
||||
for (final (names, roleType, occupationLabel) in entries) {
|
||||
final seen = <String>{};
|
||||
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 = <String>{};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <String>{};
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<String> getPersonImagesDir(String personId) async {
|
||||
final root = await imagesRoot;
|
||||
return p.join(root, 'people', personId);
|
||||
}
|
||||
|
||||
/// 获取人物图片路径
|
||||
/// 路径: images/people/{personId}/{fileName}
|
||||
Future<String> 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<void> deletePersonImages(String personId) async {
|
||||
final dirPath = await getPersonImagesDir(personId);
|
||||
await _deleteDirectory(dirPath);
|
||||
}
|
||||
|
||||
/// 删除目录及其内容
|
||||
Future<void> _deleteDirectory(String dirPath) async {
|
||||
try {
|
||||
|
||||
File diff suppressed because one or more lines are too long
434
lib/widgets/person_info_sheet.dart
Normal file
434
lib/widgets/person_info_sheet.dart
Normal file
@@ -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<void> 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<PersonInfoSheet> createState() => _PersonInfoSheetState();
|
||||
}
|
||||
|
||||
class _PersonInfoSheetState extends State<PersonInfoSheet> {
|
||||
List<MoviePerson> _moviePeople = [];
|
||||
List<BookPerson> _bookPeople = [];
|
||||
List<GamePerson> _gamePeople = [];
|
||||
bool _loading = true;
|
||||
bool _summaryExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRelations();
|
||||
}
|
||||
|
||||
Future<void> _loadRelations() async {
|
||||
final provider = context.read<AppProvider>();
|
||||
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<MoviePerson>;
|
||||
_bookPeople = results[1] as List<BookPerson>;
|
||||
_gamePeople = results[2] as List<GamePerson>;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final person = context.watch<AppProvider>().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<AppProvider>();
|
||||
|
||||
final movieGroups = <String, List<MoviePerson>>{};
|
||||
for (final mp in _moviePeople) {
|
||||
movieGroups.putIfAbsent(mp.movieId, () => []).add(mp);
|
||||
}
|
||||
final bookGroups = <String, List<BookPerson>>{};
|
||||
for (final bp in _bookPeople) {
|
||||
bookGroups.putIfAbsent(bp.bookId, () => []).add(bp);
|
||||
}
|
||||
final gameGroups = <String, List<GamePerson>>{};
|
||||
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<String> 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 = <String>{};
|
||||
final roles = <String>[];
|
||||
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 = <String>{};
|
||||
final roles = <String>[];
|
||||
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 = <String>{};
|
||||
final roles = <String>[];
|
||||
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')}日';
|
||||
}
|
||||
}
|
||||
269
lib/widgets/work_people_section.dart
Normal file
269
lib/widgets/work_people_section.dart
Normal file
@@ -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<WorkPeopleSection> createState() => _WorkPeopleSectionState();
|
||||
}
|
||||
|
||||
class _WorkPeopleSectionState extends State<WorkPeopleSection> {
|
||||
List<_PersonRole> _items = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final provider = context.read<AppProvider>();
|
||||
// 确保 people 已加载,否则 person 查找全部失败
|
||||
if (provider.people.isEmpty) {
|
||||
await provider.loadPeople();
|
||||
}
|
||||
final people = provider.people;
|
||||
|
||||
// 按 personId 聚合:同一个人可能有多条关联(导演/编剧/演员等)
|
||||
final Map<String, _PersonRole> 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 = <String>[];
|
||||
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 = <String>[];
|
||||
// 非演员角色:去重后按权重排序
|
||||
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<String> roleTypes;
|
||||
final List<String> characterNames;
|
||||
|
||||
_PersonRole({
|
||||
required this.person,
|
||||
required this.roleTypes,
|
||||
required this.characterNames,
|
||||
});
|
||||
}
|
||||
737
lib/widgets/work_selector_page.dart
Normal file
737
lib/widgets/work_selector_page.dart
Normal file
@@ -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<MoviePerson> movies;
|
||||
final List<BookPerson> books;
|
||||
final List<GamePerson> 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<MoviePerson> initialMovies;
|
||||
final List<BookPerson> initialBooks;
|
||||
final List<GamePerson> initialGames;
|
||||
|
||||
const WorkSelectorPage({
|
||||
super.key,
|
||||
required this.personId,
|
||||
required this.initialMovies,
|
||||
required this.initialBooks,
|
||||
required this.initialGames,
|
||||
});
|
||||
|
||||
static Future<WorkSelectionResult?> show({
|
||||
required BuildContext context,
|
||||
required String personId,
|
||||
required List<MoviePerson> initialMovies,
|
||||
required List<BookPerson> initialBooks,
|
||||
required List<GamePerson> initialGames,
|
||||
}) {
|
||||
return Navigator.push<WorkSelectionResult>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => WorkSelectorPage(
|
||||
personId: personId,
|
||||
initialMovies: initialMovies,
|
||||
initialBooks: initialBooks,
|
||||
initialGames: initialGames,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<WorkSelectorPage> createState() => _WorkSelectorPageState();
|
||||
}
|
||||
|
||||
class _WorkSelectorPageState extends State<WorkSelectorPage> {
|
||||
/// 所有已选的"作品+角色"条目(影视/书籍/游戏混在一起,靠 workType 区分)
|
||||
final List<_WorkRoleEntry> _entries = [];
|
||||
|
||||
/// 当前 Tab:0=影视 1=书籍 2=游戏
|
||||
int _tabIndex = 0;
|
||||
String _query = '';
|
||||
|
||||
/// 搜索模式:0=按作品标题 1=按人物名称
|
||||
int _searchMode = 0;
|
||||
|
||||
/// 人物名称搜索结果:personId → 该人物在当前 Tab 作品类型下的关联作品 ID 集合
|
||||
List<Person> _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<void> _searchByPerson(String keyword) async {
|
||||
final trimmed = keyword.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
setState(() {
|
||||
_matchedPeople = [];
|
||||
_searching = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() => _searching = true);
|
||||
final provider = context.read<AppProvider>();
|
||||
final people = await provider.searchPeople(trimmed);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_matchedPeople = people;
|
||||
_searching = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// 获取人物在当前 Tab 类型下的作品 ID 集合
|
||||
Future<Set<String>> _getPersonWorkIds(Person person) async {
|
||||
final provider = context.read<AppProvider>();
|
||||
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<AppProvider>();
|
||||
|
||||
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<Set<String>>(
|
||||
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<String> 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<String>(
|
||||
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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user