添加角色信息编辑

This commit is contained in:
DelLevin-Home
2026-08-09 01:22:35 +08:00
parent df8335b187
commit c95a659921
17 changed files with 2657 additions and 14 deletions

View File

@@ -0,0 +1,85 @@
import 'package:flutter/foundation.dart';
import '../../models/data_models.dart';
import '../database_helper.dart';
/// 书籍角色数据访问对象
class BookCharacterDao {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
Future<T> _wrap<T>(String op, Future<T> Function() fn) async {
try {
return await fn();
} catch (e) {
debugPrint('[BookCharacterDao] $op error: $e');
rethrow;
}
}
/// 获取书籍的所有角色
Future<List<BookCharacter>> getByBookId(String bookId) => _wrap('getByBookId', () async {
final db = await _dbHelper.database;
final maps = await db.query(
'book_characters',
where: 'book_id = ? AND is_deleted = 0',
whereArgs: [bookId],
orderBy: 'sort_order, created_at',
);
return maps.map((m) => BookCharacter.fromJson(m)).toList();
});
/// 根据ID获取角色
Future<BookCharacter?> getById(String id) => _wrap('getById', () async {
final db = await _dbHelper.database;
final maps = await db.query(
'book_characters',
where: 'id = ? AND is_deleted = 0',
whereArgs: [id],
);
if (maps.isEmpty) return null;
return BookCharacter.fromJson(maps.first);
});
/// 添加角色
Future<int> insert(BookCharacter character) => _wrap('insert', () async {
final db = await _dbHelper.database;
return await db.insert('book_characters', character.toJson());
});
/// 更新角色
Future<int> update(BookCharacter character) => _wrap('update', () async {
final db = await _dbHelper.database;
return await db.update(
'book_characters',
character.toJson(),
where: 'id = ?',
whereArgs: [character.id],
);
});
/// 软删除角色
Future<int> delete(String id) => _wrap('delete', () async {
final db = await _dbHelper.database;
return await db.update(
'book_characters',
{'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
});
/// 获取书籍的角色数量
Future<int> getCount(String bookId) => _wrap('getCount', () async {
final db = await _dbHelper.database;
final result = await db.rawQuery(
'SELECT COUNT(*) as count FROM book_characters WHERE book_id = ? AND is_deleted = 0',
[bookId],
);
return result.first['count'] as int? ?? 0;
});
/// 彻底删除角色
Future<void> permanentDelete(String id) => _wrap('permanentDelete', () async {
final db = await _dbHelper.database;
await db.delete('book_characters', where: 'id = ?', whereArgs: [id]);
});
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/foundation.dart';
import '../../models/data_models.dart';
import '../database_helper.dart';
/// 游戏角色数据访问对象
class GameCharacterDao {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
Future<T> _wrap<T>(String op, Future<T> Function() fn) async {
try {
return await fn();
} catch (e) {
debugPrint('[GameCharacterDao] $op error: $e');
rethrow;
}
}
/// 获取游戏的所有角色
Future<List<GameCharacter>> getByGameId(String gameId) => _wrap('getByGameId', () async {
final db = await _dbHelper.database;
final maps = await db.query(
'game_characters',
where: 'game_id = ? AND is_deleted = 0',
whereArgs: [gameId],
orderBy: 'sort_order, created_at',
);
return maps.map((m) => GameCharacter.fromJson(m)).toList();
});
/// 根据ID获取角色
Future<GameCharacter?> getById(String id) => _wrap('getById', () async {
final db = await _dbHelper.database;
final maps = await db.query(
'game_characters',
where: 'id = ? AND is_deleted = 0',
whereArgs: [id],
);
if (maps.isEmpty) return null;
return GameCharacter.fromJson(maps.first);
});
/// 添加角色
Future<int> insert(GameCharacter character) => _wrap('insert', () async {
final db = await _dbHelper.database;
return await db.insert('game_characters', character.toJson());
});
/// 更新角色
Future<int> update(GameCharacter character) => _wrap('update', () async {
final db = await _dbHelper.database;
return await db.update(
'game_characters',
character.toJson(),
where: 'id = ?',
whereArgs: [character.id],
);
});
/// 软删除角色
Future<int> delete(String id) => _wrap('delete', () async {
final db = await _dbHelper.database;
return await db.update(
'game_characters',
{'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
});
/// 获取游戏的角色数量
Future<int> getCount(String gameId) => _wrap('getCount', () async {
final db = await _dbHelper.database;
final result = await db.rawQuery(
'SELECT COUNT(*) as count FROM game_characters WHERE game_id = ? AND is_deleted = 0',
[gameId],
);
return result.first['count'] as int? ?? 0;
});
/// 彻底删除角色
Future<void> permanentDelete(String id) => _wrap('permanentDelete', () async {
final db = await _dbHelper.database;
await db.delete('game_characters', where: 'id = ?', whereArgs: [id]);
});
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/foundation.dart';
import '../../models/data_models.dart';
import '../database_helper.dart';
/// 影视角色数据访问对象
class MovieCharacterDao {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
Future<T> _wrap<T>(String op, Future<T> Function() fn) async {
try {
return await fn();
} catch (e) {
debugPrint('[MovieCharacterDao] $op error: $e');
rethrow;
}
}
/// 获取影视的所有角色
Future<List<MovieCharacter>> getByMovieId(String movieId) => _wrap('getByMovieId', () async {
final db = await _dbHelper.database;
final maps = await db.query(
'movie_characters',
where: 'movie_id = ? AND is_deleted = 0',
whereArgs: [movieId],
orderBy: 'sort_order, created_at',
);
return maps.map((m) => MovieCharacter.fromJson(m)).toList();
});
/// 根据ID获取角色
Future<MovieCharacter?> getById(String id) => _wrap('getById', () async {
final db = await _dbHelper.database;
final maps = await db.query(
'movie_characters',
where: 'id = ? AND is_deleted = 0',
whereArgs: [id],
);
if (maps.isEmpty) return null;
return MovieCharacter.fromJson(maps.first);
});
/// 添加角色
Future<int> insert(MovieCharacter character) => _wrap('insert', () async {
final db = await _dbHelper.database;
return await db.insert('movie_characters', character.toJson());
});
/// 更新角色
Future<int> update(MovieCharacter character) => _wrap('update', () async {
final db = await _dbHelper.database;
return await db.update(
'movie_characters',
character.toJson(),
where: 'id = ?',
whereArgs: [character.id],
);
});
/// 软删除角色
Future<int> delete(String id) => _wrap('delete', () async {
final db = await _dbHelper.database;
return await db.update(
'movie_characters',
{'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
});
/// 获取影视的角色数量
Future<int> getCount(String movieId) => _wrap('getCount', () async {
final db = await _dbHelper.database;
final result = await db.rawQuery(
'SELECT COUNT(*) as count FROM movie_characters WHERE movie_id = ? AND is_deleted = 0',
[movieId],
);
return result.first['count'] as int? ?? 0;
});
/// 彻底删除角色
Future<void> permanentDelete(String id) => _wrap('permanentDelete', () async {
final db = await _dbHelper.database;
await db.delete('movie_characters', where: 'id = ?', whereArgs: [id]);
});
}

View File

@@ -81,7 +81,7 @@ class DatabaseHelper {
return await openDatabase(
path,
version: 39,
version: 40,
onCreate: _createDB,
onUpgrade: _onUpgrade,
);
@@ -405,6 +405,14 @@ class DatabaseHelper {
// 创建人物表和关联表
await _createPeopleTables(db);
}
if (oldVersion < 40) {
// 创建角色表
await _createCharacterTables(db);
// 早期 v40 迭代可能已建表但缺列,补齐缺失列
await _ensureCharacterColumns(db, 'movie_characters');
await _ensureCharacterColumns(db, 'book_characters');
await _ensureCharacterColumns(db, 'game_characters');
}
}
Future<void> _upgradeBooksTableV26(Database db) async {
final columns = await db.rawQuery('PRAGMA table_info(books)');
@@ -1040,6 +1048,8 @@ class DatabaseHelper {
// 人物表
await _createPeopleTables(db);
// 角色表
await _createCharacterTables(db);
}
/// 创建人物表和关联表
@@ -1107,6 +1117,85 @@ class DatabaseHelper {
await db.execute('CREATE INDEX IF NOT EXISTS idx_game_people_person ON game_people(person_id)');
}
/// 创建角色表(影视/书籍/游戏)
Future<void> _createCharacterTables(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS movie_characters (
id TEXT PRIMARY KEY,
movie_id TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT,
aliases TEXT DEFAULT '[]',
tags TEXT DEFAULT '[]',
description TEXT,
image_path TEXT,
sort_order INTEGER DEFAULT 0,
is_deleted INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('CREATE INDEX IF NOT EXISTS idx_movie_characters_movie ON movie_characters(movie_id)');
await db.execute('''
CREATE TABLE IF NOT EXISTS book_characters (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT,
aliases TEXT DEFAULT '[]',
tags TEXT DEFAULT '[]',
description TEXT,
image_path TEXT,
sort_order INTEGER DEFAULT 0,
is_deleted INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('CREATE INDEX IF NOT EXISTS idx_book_characters_book ON book_characters(book_id)');
await db.execute('''
CREATE TABLE IF NOT EXISTS game_characters (
id TEXT PRIMARY KEY,
game_id TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT,
aliases TEXT DEFAULT '[]',
tags TEXT DEFAULT '[]',
description TEXT,
image_path TEXT,
sort_order INTEGER DEFAULT 0,
is_deleted INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('CREATE INDEX IF NOT EXISTS idx_game_characters_game ON game_characters(game_id)');
}
/// 补齐角色表缺失的列(早期 v40 迭代建表时可能未包含)
Future<void> _ensureCharacterColumns(Database db, String table) async {
final columns = await db.rawQuery('PRAGMA table_info($table)');
final names = columns.map((c) => c['name'] as String).toSet();
const additions = <String, String>{
'role': 'TEXT',
'aliases': "TEXT DEFAULT '[]'",
'tags': "TEXT DEFAULT '[]'",
'description': 'TEXT',
'image_path': 'TEXT',
'sort_order': 'INTEGER DEFAULT 0',
'is_deleted': 'INTEGER DEFAULT 0',
'created_at': "TEXT DEFAULT ''",
'updated_at': "TEXT DEFAULT ''",
};
for (final entry in additions.entries) {
if (!names.contains(entry.key)) {
await db.execute('ALTER TABLE $table ADD COLUMN ${entry.key} ${entry.value}');
}
}
}
// 关闭数据库
Future close() async {
if (_database != null) {

View File

@@ -1365,3 +1365,321 @@ class GamePerson {
}
}
/// 影视角色模型
class MovieCharacter {
final String id;
final String movieId;
final String name;
final String? role;
final List<String> aliases;
final List<String> tags;
final String? description;
final String? imagePath;
final int sortOrder;
final bool isDeleted;
final DateTime createdAt;
final DateTime updatedAt;
MovieCharacter({
required this.id,
required this.movieId,
required this.name,
this.role,
this.aliases = const [],
this.tags = const [],
this.description,
this.imagePath,
this.sortOrder = 0,
this.isDeleted = false,
required this.createdAt,
required this.updatedAt,
});
factory MovieCharacter.fromJson(Map<String, dynamic> json) {
return MovieCharacter(
id: json['id']?.toString() ?? '',
movieId: json['movie_id']?.toString() ?? '',
name: json['name'] ?? '',
role: json['role'],
aliases: parseStringListGeneric(json['aliases']),
tags: parseStringListGeneric(json['tags']),
description: json['description'],
imagePath: json['image_path'],
sortOrder: json['sort_order'] ?? 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,
'movie_id': movieId,
'name': name,
'role': role,
'aliases': jsonEncode(aliases),
'tags': jsonEncode(tags),
'description': description,
'image_path': imagePath,
'sort_order': sortOrder,
'is_deleted': isDeleted ? 1 : 0,
'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toUtc().toIso8601String(),
};
}
MovieCharacter copyWith({
String? id,
String? movieId,
String? name,
Object? role = _copyWithNull,
List<String>? aliases,
List<String>? tags,
Object? description = _copyWithNull,
Object? imagePath = _copyWithNull,
int? sortOrder,
bool? isDeleted,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return MovieCharacter(
id: id ?? this.id,
movieId: movieId ?? this.movieId,
name: name ?? this.name,
role: role is _CopyWithNullSentinel ? this.role : (role as String?),
aliases: aliases ?? this.aliases,
tags: tags ?? this.tags,
description: description is _CopyWithNullSentinel ? this.description : (description as String?),
imagePath: imagePath is _CopyWithNullSentinel ? this.imagePath : (imagePath as String?),
sortOrder: sortOrder ?? this.sortOrder,
isDeleted: isDeleted ?? this.isDeleted,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
File? get imageFile {
if (imagePath == null || imagePath!.isEmpty) return null;
return File(imagePath!);
}
String get summary {
if (description == null || description!.isEmpty) return '';
if (description!.length <= 50) return description!;
return '${description!.substring(0, 50)}...';
}
}
/// 书籍角色模型
class BookCharacter {
final String id;
final String bookId;
final String name;
final String? role;
final List<String> aliases;
final List<String> tags;
final String? description;
final String? imagePath;
final int sortOrder;
final bool isDeleted;
final DateTime createdAt;
final DateTime updatedAt;
BookCharacter({
required this.id,
required this.bookId,
required this.name,
this.role,
this.aliases = const [],
this.tags = const [],
this.description,
this.imagePath,
this.sortOrder = 0,
this.isDeleted = false,
required this.createdAt,
required this.updatedAt,
});
factory BookCharacter.fromJson(Map<String, dynamic> json) {
return BookCharacter(
id: json['id']?.toString() ?? '',
bookId: json['book_id']?.toString() ?? '',
name: json['name'] ?? '',
role: json['role'],
aliases: parseStringListGeneric(json['aliases']),
tags: parseStringListGeneric(json['tags']),
description: json['description'],
imagePath: json['image_path'],
sortOrder: json['sort_order'] ?? 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,
'book_id': bookId,
'name': name,
'role': role,
'aliases': jsonEncode(aliases),
'tags': jsonEncode(tags),
'description': description,
'image_path': imagePath,
'sort_order': sortOrder,
'is_deleted': isDeleted ? 1 : 0,
'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toUtc().toIso8601String(),
};
}
BookCharacter copyWith({
String? id,
String? bookId,
String? name,
Object? role = _copyWithNull,
List<String>? aliases,
List<String>? tags,
Object? description = _copyWithNull,
Object? imagePath = _copyWithNull,
int? sortOrder,
bool? isDeleted,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return BookCharacter(
id: id ?? this.id,
bookId: bookId ?? this.bookId,
name: name ?? this.name,
role: role is _CopyWithNullSentinel ? this.role : (role as String?),
aliases: aliases ?? this.aliases,
tags: tags ?? this.tags,
description: description is _CopyWithNullSentinel ? this.description : (description as String?),
imagePath: imagePath is _CopyWithNullSentinel ? this.imagePath : (imagePath as String?),
sortOrder: sortOrder ?? this.sortOrder,
isDeleted: isDeleted ?? this.isDeleted,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
File? get imageFile {
if (imagePath == null || imagePath!.isEmpty) return null;
return File(imagePath!);
}
String get summary {
if (description == null || description!.isEmpty) return '';
if (description!.length <= 50) return description!;
return '${description!.substring(0, 50)}...';
}
}
/// 游戏角色模型
class GameCharacter {
final String id;
final String gameId;
final String name;
final String? role;
final List<String> aliases;
final List<String> tags;
final String? description;
final String? imagePath;
final int sortOrder;
final bool isDeleted;
final DateTime createdAt;
final DateTime updatedAt;
GameCharacter({
required this.id,
required this.gameId,
required this.name,
this.role,
this.aliases = const [],
this.tags = const [],
this.description,
this.imagePath,
this.sortOrder = 0,
this.isDeleted = false,
required this.createdAt,
required this.updatedAt,
});
factory GameCharacter.fromJson(Map<String, dynamic> json) {
return GameCharacter(
id: json['id']?.toString() ?? '',
gameId: json['game_id']?.toString() ?? '',
name: json['name'] ?? '',
role: json['role'],
aliases: parseStringListGeneric(json['aliases']),
tags: parseStringListGeneric(json['tags']),
description: json['description'],
imagePath: json['image_path'],
sortOrder: json['sort_order'] ?? 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,
'game_id': gameId,
'name': name,
'role': role,
'aliases': jsonEncode(aliases),
'tags': jsonEncode(tags),
'description': description,
'image_path': imagePath,
'sort_order': sortOrder,
'is_deleted': isDeleted ? 1 : 0,
'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toUtc().toIso8601String(),
};
}
GameCharacter copyWith({
String? id,
String? gameId,
String? name,
Object? role = _copyWithNull,
List<String>? aliases,
List<String>? tags,
Object? description = _copyWithNull,
Object? imagePath = _copyWithNull,
int? sortOrder,
bool? isDeleted,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return GameCharacter(
id: id ?? this.id,
gameId: gameId ?? this.gameId,
name: name ?? this.name,
role: role is _CopyWithNullSentinel ? this.role : (role as String?),
aliases: aliases ?? this.aliases,
tags: tags ?? this.tags,
description: description is _CopyWithNullSentinel ? this.description : (description as String?),
imagePath: imagePath is _CopyWithNullSentinel ? this.imagePath : (imagePath as String?),
sortOrder: sortOrder ?? this.sortOrder,
isDeleted: isDeleted ?? this.isDeleted,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
File? get imageFile {
if (imagePath == null || imagePath!.isEmpty) return null;
return File(imagePath!);
}
String get summary {
if (description == null || description!.isEmpty) return '';
if (description!.length <= 50) return description!;
return '${description!.substring(0, 50)}...';
}
}

View File

@@ -16,9 +16,12 @@ import '../../utils/image_path_helper.dart';
import '../../utils/responsive.dart';
import '../../widgets/genre_selector_page.dart';
import '../../widgets/work_people_section.dart';
import '../../widgets/character_preview_section.dart';
import '../../widgets/character_info_sheet.dart';
import 'book_reviews_page.dart';
import 'book_excerpts_page.dart';
import 'book_share_page.dart';
import '../character/character_list_page.dart';
import '../../data/epub/reader_dao.dart';
import '../epub_reader/epub_highlights_page.dart';
import '../epub_reader/reader_screen.dart';
@@ -44,6 +47,9 @@ class _BookDetailPageState extends State<BookDetailPage> {
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
ScrollController? _overlayScrollController;
// ─── 角色预览 ───
List<dynamic> _characters = [];
// ─── 编辑模式 ───
bool _isEditing = false;
final _editFormKey = GlobalKey<FormState>();
@@ -84,6 +90,13 @@ class _BookDetailPageState extends State<BookDetailPage> {
_detailStyle = UserPrefs().detailPageStyle;
_coverOffset.value = UserPrefs().getCoverOffset(widget.book.id);
_initEditControllers();
_loadCharacters();
}
Future<void> _loadCharacters() async {
final list = await context.read<AppProvider>().getBookCharacters(widget.book.id);
if (!mounted) return;
setState(() => _characters = list);
}
void _initEditControllers() {
@@ -268,6 +281,10 @@ class _BookDetailPageState extends State<BookDetailPage> {
Expanded(child: Text('${book.readCount}', style: TextStyle(fontSize: 13, color: colors.onSurface))),
]),
],
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
),
WorkPeopleSection(workId: book.id, workType: 'book'),
if (book.summary != null && book.summary!.isNotEmpty) ...[
Divider(height: 32, thickness: 0.5, color: colors.outline),
@@ -312,6 +329,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
unit: '条句读',
onTap: () => _navigateToEpubHighlights(book),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getBookCharacterCount(book.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(book),
),
],
),
),
@@ -808,6 +834,10 @@ 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),
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
),
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),
@@ -911,6 +941,11 @@ class _BookDetailPageState extends State<BookDetailPage> {
if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book),
// 类型标签毛玻璃
if (book.genres.isNotEmpty) _buildGenresSection(book),
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
isOverlay: true,
),
// 关联人物
WorkPeopleSection(workId: book.id, workType: 'book'),
// 简介:内部已有毛玻璃卡片
@@ -996,6 +1031,14 @@ class _BookDetailPageState extends State<BookDetailPage> {
foregroundColor: colors.onPrimary,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.people_outline,
onPressed: () => _navigateToCharacters(book),
tooltip: '角色',
backgroundColor: colors.secondaryContainer,
foregroundColor: colors.onSecondaryContainer,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.delete_outline,
onPressed: () => _showDeleteDialog(context),
@@ -1866,6 +1909,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
unit: '条句读',
onTap: () => _navigateToEpubHighlights(book),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getBookCharacterCount(book.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(book),
),
],
),
);
@@ -1904,6 +1956,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
unit: '条句读',
onTap: () => _navigateToEpubHighlights(book),
),
const SizedBox(height: 12),
_buildFrostedExtraItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getBookCharacterCount(book.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(book),
),
],
),
);
@@ -2052,6 +2113,25 @@ class _BookDetailPageState extends State<BookDetailPage> {
);
}
void _navigateToCharacters(Book book) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookCharactersPage(book: book),
),
).then((_) => _loadCharacters());
}
Future<void> _openCharacterSheet(dynamic character) async {
final needRefresh = await CharacterInfoSheet.show(
context,
entityType: 'book',
entityId: widget.book.id,
character: character,
);
if (needRefresh == true) _loadCharacters();
}
/// 获取关联 EPUB 的句读(高亮)数量
Future<int> _getEpubHighlightCount(String bookId) async {
final readerBook = await ReaderDao().getReaderBookByBookId(bookId);

View File

@@ -0,0 +1,553 @@
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';
/// 角色编辑/添加页面
///
/// entityType: 'movie' / 'book' / 'game'
/// entityId: 所属作品 ID
/// character: 可空,空=新建
class CharacterFormPage extends StatefulWidget {
final String entityType;
final String entityId;
final dynamic character; // MovieCharacter / BookCharacter / GameCharacter
const CharacterFormPage({
super.key,
required this.entityType,
required this.entityId,
this.character,
});
@override
State<CharacterFormPage> createState() => _CharacterFormPageState();
}
class _CharacterFormPageState extends State<CharacterFormPage> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _roleCtrl = TextEditingController();
final _descCtrl = TextEditingController();
final ImagePicker _picker = ImagePicker();
List<String> _aliases = [];
List<String> _tags = [];
String? _imagePath;
bool _isDownloading = false;
@override
void initState() {
super.initState();
if (widget.character != null) {
final c = widget.character;
_nameCtrl.text = c.name;
_roleCtrl.text = c.role ?? '';
_descCtrl.text = c.description ?? '';
_aliases = List<String>.from(c.aliases);
_tags = List<String>.from(c.tags);
_imagePath = c.imagePath;
}
}
@override
void dispose() {
_nameCtrl.dispose();
_roleCtrl.dispose();
_descCtrl.dispose();
super.dispose();
}
String get _entityLabel => switch (widget.entityType) {
'movie' => '影视',
'book' => '书籍',
'game' => '游戏',
_ => '作品',
};
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final isEdit = widget.character != 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: _buildImagePicker(colors)),
const SizedBox(height: 24),
_buildField('名称', _nameCtrl, hint: '角色名称', required: true),
const SizedBox(height: 16),
_buildField('角色定位', _roleCtrl, hint: '如:男主、女主、反派、配角'),
const SizedBox(height: 16),
_buildChipField('别名', _aliases, colors, onTap: () async {
final result = await GenreSelectorPage.show(
context: context,
title: '添加别名',
existingTags: [],
initialSelected: _aliases,
hint: '如:曾用名、英文名',
);
if (result != null) setState(() => _aliases = result);
}),
const SizedBox(height: 16),
_buildChipField('标签', _tags, colors, onTap: () async {
final result = await GenreSelectorPage.show(
context: context,
title: '添加标签',
existingTags: [],
initialSelected: _tags,
hint: '如:主角、反派',
);
if (result != null) setState(() => _tags = result);
}),
const SizedBox(height: 16),
_buildSectionLabel('角色简介', colors),
const SizedBox(height: 6),
Container(
constraints: const BoxConstraints(minHeight: 120),
child: TextFormField(
controller: _descCtrl,
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 _buildImagePicker(ColorScheme colors) {
final hasImage = _imagePath != null && _imagePath!.isNotEmpty;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: _showImageOptions,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
clipBehavior: Clip.antiAlias,
child: Stack(
alignment: Alignment.center,
children: [
if (hasImage)
FadeInLocalImage(path: _imagePath, 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 (hasImage)
Padding(
padding: const EdgeInsets.only(top: 8),
child: GestureDetector(
onTap: () => setState(() => _imagePath = null),
child: Text('移除图片', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
),
),
],
);
}
void _showImageOptions() {
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); _pickImage(); },
),
ListTile(
leading: Icon(Icons.link_outlined, color: colors.onSurface.withValues(alpha: 0.6)),
title: Text('网络链接', style: TextStyle(color: colors.onSurface)),
onTap: () { Navigator.pop(ctx); _pickImageFromUrl(); },
),
],
),
),
),
);
}
Future<String> _characterId() async {
if (widget.character != null) return widget.character.id;
return const Uuid().v4();
}
Future<void> _pickImage() async {
try {
final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, maxWidth: 600, maxHeight: 600, imageQuality: 85);
if (picked == null) return;
final fileName = 'char_${DateTime.now().millisecondsSinceEpoch}.jpg';
final charId = await _characterId();
final targetPath = await ImagePathHelper.instance.getCharacterImagePath(charId, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(picked.path).copy(targetPath);
if (mounted) setState(() => _imagePath = targetPath);
} catch (e) {
if (mounted) ToastUtil.show(context, '选择图片失败: $e');
}
}
Future<void> _pickImageFromUrl() 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 _downloadImageFromUrl(url!);
}
Future<void> _downloadImageFromUrl(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 = 'char_${DateTime.now().millisecondsSinceEpoch}.jpg';
final charId = await _characterId();
final targetPath = await ImagePathHelper.instance.getCharacterImagePath(charId, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(targetPath).writeAsBytes(response.bodyBytes);
if (!mounted) return;
setState(() => _imagePath = 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}) {
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,
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 _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.character != null) return true;
if (_nameCtrl.text.trim().isNotEmpty) return true;
if (_roleCtrl.text.trim().isNotEmpty) return true;
if (_descCtrl.text.trim().isNotEmpty) return true;
if (_imagePath != null) return true;
if (_aliases.isNotEmpty || _tags.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>();
final desc = _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim();
final role = _roleCtrl.text.trim().isEmpty ? null : _roleCtrl.text.trim();
if (widget.character == null) {
// 新建
final newId = const Uuid().v4();
switch (widget.entityType) {
case 'movie':
await provider.addMovieCharacter(MovieCharacter(
id: newId,
movieId: widget.entityId,
name: _nameCtrl.text.trim(),
role: role,
aliases: _aliases,
tags: _tags,
description: desc,
imagePath: _imagePath,
createdAt: now,
updatedAt: now,
));
break;
case 'book':
await provider.addBookCharacter(BookCharacter(
id: newId,
bookId: widget.entityId,
name: _nameCtrl.text.trim(),
role: role,
aliases: _aliases,
tags: _tags,
description: desc,
imagePath: _imagePath,
createdAt: now,
updatedAt: now,
));
break;
case 'game':
await provider.addGameCharacter(GameCharacter(
id: newId,
gameId: widget.entityId,
name: _nameCtrl.text.trim(),
role: role,
aliases: _aliases,
tags: _tags,
description: desc,
imagePath: _imagePath,
createdAt: now,
updatedAt: now,
));
break;
}
} else {
// 编辑
final c = widget.character;
switch (widget.entityType) {
case 'movie':
await provider.updateMovieCharacter((c as MovieCharacter).copyWith(
name: _nameCtrl.text.trim(),
role: role,
aliases: _aliases,
tags: _tags,
description: desc,
imagePath: _imagePath,
updatedAt: now,
));
break;
case 'book':
await provider.updateBookCharacter((c as BookCharacter).copyWith(
name: _nameCtrl.text.trim(),
role: role,
aliases: _aliases,
tags: _tags,
description: desc,
imagePath: _imagePath,
updatedAt: now,
));
break;
case 'game':
await provider.updateGameCharacter((c as GameCharacter).copyWith(
name: _nameCtrl.text.trim(),
role: role,
aliases: _aliases,
tags: _tags,
description: desc,
imagePath: _imagePath,
updatedAt: now,
));
break;
}
}
if (!mounted) return;
ToastUtil.show(context, widget.character == null ? '添加成功' : '更新成功');
Navigator.pop(context, true);
} catch (e) {
if (!mounted) return;
ToastUtil.show(context, '保存失败: $e');
}
}
}

View File

@@ -0,0 +1,451 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.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 'character_form_page.dart';
/// 影视角色列表页
class MovieCharactersPage extends StatefulWidget {
final Movie movie;
const MovieCharactersPage({super.key, required this.movie});
@override
State<MovieCharactersPage> createState() => _MovieCharactersPageState();
}
class _MovieCharactersPageState extends State<MovieCharactersPage> {
List<MovieCharacter> _characters = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadCharacters();
}
Future<void> _loadCharacters() async {
setState(() => _isLoading = true);
final list = await context.read<AppProvider>().getMovieCharacters(widget.movie.id);
if (!mounted) return;
setState(() {
_characters = list;
_isLoading = false;
});
}
Future<void> _openForm([MovieCharacter? c]) async {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => CharacterFormPage(
entityType: 'movie',
entityId: widget.movie.id,
character: c,
),
),
);
if (result == true) _loadCharacters();
}
Future<void> _delete(MovieCharacter c) async {
if (c.imagePath != null && c.imagePath!.isNotEmpty) {
await ImagePathHelper.instance.deleteCharacterImages(c.id);
}
await context.read<AppProvider>().deleteMovieCharacter(c.id);
_loadCharacters();
if (mounted) ToastUtil.show(context, '已删除');
}
@override
Widget build(BuildContext context) {
return _CharacterListScaffold(
title: '角色',
isLoading: _isLoading,
characters: _characters,
onAdd: () => _openForm(),
onTap: (c) => _openForm(c),
onDelete: (c) => _delete(c),
);
}
}
/// 书籍角色列表页
class BookCharactersPage extends StatefulWidget {
final Book book;
const BookCharactersPage({super.key, required this.book});
@override
State<BookCharactersPage> createState() => _BookCharactersPageState();
}
class _BookCharactersPageState extends State<BookCharactersPage> {
List<BookCharacter> _characters = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadCharacters();
}
Future<void> _loadCharacters() async {
setState(() => _isLoading = true);
final list = await context.read<AppProvider>().getBookCharacters(widget.book.id);
if (!mounted) return;
setState(() {
_characters = list;
_isLoading = false;
});
}
Future<void> _openForm([BookCharacter? c]) async {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => CharacterFormPage(
entityType: 'book',
entityId: widget.book.id,
character: c,
),
),
);
if (result == true) _loadCharacters();
}
Future<void> _delete(BookCharacter c) async {
if (c.imagePath != null && c.imagePath!.isNotEmpty) {
await ImagePathHelper.instance.deleteCharacterImages(c.id);
}
await context.read<AppProvider>().deleteBookCharacter(c.id);
_loadCharacters();
if (mounted) ToastUtil.show(context, '已删除');
}
@override
Widget build(BuildContext context) {
return _CharacterListScaffold(
title: '角色',
isLoading: _isLoading,
characters: _characters,
onAdd: () => _openForm(),
onTap: (c) => _openForm(c),
onDelete: (c) => _delete(c),
);
}
}
/// 游戏角色列表页
class GameCharactersPage extends StatefulWidget {
final Game game;
const GameCharactersPage({super.key, required this.game});
@override
State<GameCharactersPage> createState() => _GameCharactersPageState();
}
class _GameCharactersPageState extends State<GameCharactersPage> {
List<GameCharacter> _characters = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadCharacters();
}
Future<void> _loadCharacters() async {
setState(() => _isLoading = true);
final list = await context.read<AppProvider>().getGameCharacters(widget.game.id);
if (!mounted) return;
setState(() {
_characters = list;
_isLoading = false;
});
}
Future<void> _openForm([GameCharacter? c]) async {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => CharacterFormPage(
entityType: 'game',
entityId: widget.game.id,
character: c,
),
),
);
if (result == true) _loadCharacters();
}
Future<void> _delete(GameCharacter c) async {
if (c.imagePath != null && c.imagePath!.isNotEmpty) {
await ImagePathHelper.instance.deleteCharacterImages(c.id);
}
await context.read<AppProvider>().deleteGameCharacter(c.id);
_loadCharacters();
if (mounted) ToastUtil.show(context, '已删除');
}
@override
Widget build(BuildContext context) {
return _CharacterListScaffold(
title: '角色',
isLoading: _isLoading,
characters: _characters,
onAdd: () => _openForm(),
onTap: (c) => _openForm(c),
onDelete: (c) => _delete(c),
);
}
}
/// 通用角色列表 UI接收 dynamic 角色列表,访问 .name/.aliases/.tags/.imagePath/.description
class _CharacterListScaffold extends StatelessWidget {
final String title;
final bool isLoading;
final List<dynamic> characters;
final VoidCallback onAdd;
final void Function(dynamic) onTap;
final Future<void> Function(dynamic) onDelete;
const _CharacterListScaffold({
required this.title,
required this.isLoading,
required this.characters,
required this.onAdd,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: Text(title)),
floatingActionButton: FloatingActionButton(
onPressed: onAdd,
child: const Icon(Icons.add),
),
body: isLoading
? const Center(child: CircularProgressIndicator())
: characters.isEmpty
? _buildEmpty(colors)
: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: characters.length,
separatorBuilder: (_, __) => Divider(height: 1, thickness: 0.5, color: colors.outlineVariant),
itemBuilder: (context, index) {
final c = characters[index];
return _CharacterTile(
name: c.name as String,
aliases: c.aliases as List<String>,
tags: c.tags as List<String>,
description: c.description as String?,
imagePath: c.imagePath as String?,
onTap: () => onTap(c),
onDelete: () => _confirmDelete(context, c),
);
},
),
);
}
Widget _buildEmpty(ColorScheme colors) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Icon(Icons.people_outline, size: 40, color: colors.onSurface.withValues(alpha: 0.25)),
),
const SizedBox(height: 20),
Text('暂无角色', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 8),
Text('点击右下角 + 添加角色', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))),
],
),
);
}
void _confirmDelete(BuildContext context, dynamic c) {
final colors = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: Text('确定要删除该角色吗?',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
onDelete(c);
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.error,
foregroundColor: colors.onError,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
}
class _CharacterTile extends StatelessWidget {
final String name;
final List<String> aliases;
final List<String> tags;
final String? description;
final String? imagePath;
final VoidCallback onTap;
final VoidCallback onDelete;
const _CharacterTile({
required this.name,
required this.aliases,
required this.tags,
required this.description,
required this.imagePath,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Dismissible(
key: ValueKey(name + imagePath.toString()),
direction: DismissDirection.endToStart,
background: Container(
color: colors.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: Icon(Icons.delete_outline, color: colors.onError),
),
confirmDismiss: (_) async {
_showDeleteDialog(context);
return false;
},
child: ListTile(
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: _buildAvatar(colors),
title: Text(name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (aliases.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(aliases.join(''),
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
),
if (tags.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: tags.map((t) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(t, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.6))),
)).toList(),
),
),
if (description != null && description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4), height: 1.4)),
),
],
),
trailing: Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)),
),
);
}
Widget _buildAvatar(ColorScheme colors) {
final hasImage = imagePath != null && imagePath!.isNotEmpty;
return Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
clipBehavior: Clip.antiAlias,
child: hasImage
? FadeInLocalImage(path: imagePath, fit: BoxFit.cover)
: Center(
child: Text(
name.isNotEmpty ? name.characters.first : '?',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5)),
),
),
);
}
void _showDeleteDialog(BuildContext context) {
final colors = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
content: Text('确定要删除"$name"吗?',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
onDelete();
},
style: ElevatedButton.styleFrom(
backgroundColor: colors.error,
foregroundColor: colors.onError,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
}

View File

@@ -16,9 +16,12 @@ import '../../utils/image_path_helper.dart';
import '../../utils/responsive.dart';
import '../../widgets/genre_selector_page.dart';
import '../../widgets/work_people_section.dart';
import '../../widgets/character_preview_section.dart';
import '../../widgets/character_info_sheet.dart';
import 'game_reviews_page.dart';
import 'game_screenshots_page.dart';
import 'game_share_page.dart';
import '../character/character_list_page.dart';
/// 游戏详情页 - 极简主义设计
class GameDetailPage extends StatefulWidget {
@@ -42,6 +45,9 @@ class _GameDetailPageState extends State<GameDetailPage> {
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
ScrollController? _overlayScrollController;
// ─── 角色预览 ───
List<dynamic> _characters = [];
// ─── 编辑模式 ───
bool _isEditing = false;
final _editFormKey = GlobalKey<FormState>();
@@ -71,6 +77,13 @@ class _GameDetailPageState extends State<GameDetailPage> {
_coverOffset.value = UserPrefs().getCoverOffset(widget.game.id);
_initEditControllers();
_detectCoverAspect();
_loadCharacters();
}
Future<void> _loadCharacters() async {
final list = await context.read<AppProvider>().getGameCharacters(widget.game.id);
if (!mounted) return;
setState(() => _characters = list);
}
void _initEditControllers() {
@@ -273,6 +286,10 @@ class _GameDetailPageState extends State<GameDetailPage> {
_buildDesktopInfoRow('购买时间', _formatDate(game.purchaseDate!), colors),
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
_buildDesktopInfoRow('购买价格', game.purchasePrice!, colors),
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
),
WorkPeopleSection(workId: game.id, workType: 'game'),
if (game.summary != null && game.summary!.isNotEmpty) ...[
Divider(height: 32, thickness: 0.5, color: colors.outline),
@@ -308,6 +325,15 @@ class _GameDetailPageState extends State<GameDetailPage> {
unit: '张截图',
onTap: () => _navigateToScreenshots(game),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getGameCharacterCount(game.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(game),
),
],
),
),
@@ -999,6 +1025,10 @@ class _GameDetailPageState extends State<GameDetailPage> {
_buildInfoSection('购买时间', _formatDate(game.purchaseDate!)),
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
_buildInfoSection('购买价格', game.purchasePrice!),
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
),
WorkPeopleSection(workId: game.id, workType: 'game'),
if (game.summary != null && game.summary!.isNotEmpty)
_buildInfoSection('游戏简介', game.summary!),
@@ -1149,6 +1179,11 @@ class _GameDetailPageState extends State<GameDetailPage> {
_buildOverlayInfoRow('购买时间', _formatDate(game.purchaseDate!)),
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
_buildOverlayInfoRow('购买价格', game.purchasePrice!),
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
isOverlay: true,
),
// 关联人物
WorkPeopleSection(workId: game.id, workType: 'game'),
if (game.summary != null && game.summary!.isNotEmpty) ...[
@@ -1289,6 +1324,15 @@ class _GameDetailPageState extends State<GameDetailPage> {
unit: '张截图',
onTap: () => _navigateToScreenshots(game),
),
const SizedBox(height: 12),
_buildFrostedExtraItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getGameCharacterCount(game.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(game),
),
],
),
);
@@ -1357,6 +1401,14 @@ class _GameDetailPageState extends State<GameDetailPage> {
foregroundColor: colors.onPrimary,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.people_outline,
onPressed: () => _navigateToCharacters(game),
tooltip: '角色',
backgroundColor: colors.secondaryContainer,
foregroundColor: colors.onSecondaryContainer,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.delete_outline,
onPressed: () => _showDeleteDialog(context),
@@ -1721,6 +1773,15 @@ class _GameDetailPageState extends State<GameDetailPage> {
unit: '张截图',
onTap: () => _navigateToScreenshots(game),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getGameCharacterCount(game.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(game),
),
],
),
);
@@ -1790,6 +1851,21 @@ class _GameDetailPageState extends State<GameDetailPage> {
Navigator.push(context, MaterialPageRoute(builder: (_) => GameScreenshotsPage(game: game)));
}
void _navigateToCharacters(Game game) {
Navigator.push(context, MaterialPageRoute(builder: (_) => GameCharactersPage(game: game)))
.then((_) => _loadCharacters());
}
Future<void> _openCharacterSheet(dynamic character) async {
final needRefresh = await CharacterInfoSheet.show(
context,
entityType: 'game',
entityId: widget.game.id,
character: character,
);
if (needRefresh == true) _loadCharacters();
}
void _showStylePicker() {
final colors = Theme.of(context).colorScheme;
final currentStyle = UserPrefs().detailPageStyle;

View File

@@ -16,9 +16,12 @@ import '../../utils/image_path_helper.dart';
import '../../utils/responsive.dart';
import '../../widgets/genre_selector_page.dart';
import '../../widgets/work_people_section.dart';
import '../../widgets/character_preview_section.dart';
import '../../widgets/character_info_sheet.dart';
import 'movie_reviews_page.dart';
import 'movie_posters_page.dart';
import 'movie_share_page.dart';
import '../character/character_list_page.dart';
/// 影视详情页 - 极简主义设计
class MovieDetailPage extends StatefulWidget {
@@ -42,6 +45,9 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
ScrollController? _overlayScrollController;
// ─── 角色预览 ───
List<dynamic> _characters = [];
// ─── 编辑模式 ───
bool _isEditing = false;
final _editFormKey = GlobalKey<FormState>();
@@ -68,6 +74,13 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
_detailStyle = UserPrefs().detailPageStyle;
_posterOffset.value = UserPrefs().getCoverOffset(widget.movie.id);
_initEditControllers();
_loadCharacters();
}
Future<void> _loadCharacters() async {
final list = await context.read<AppProvider>().getMovieCharacters(widget.movie.id);
if (!mounted) return;
setState(() => _characters = list);
}
void _initEditControllers() {
@@ -268,6 +281,10 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
)),
]),
],
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
),
WorkPeopleSection(workId: movie.id, workType: 'movie'),
if (movie.summary != null && movie.summary!.isNotEmpty) ...[
Divider(height: 32, thickness: 0.5, color: colors.outline),
@@ -304,6 +321,15 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
unit: '张海报',
onTap: () => _navigateToPosters(movie),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getMovieCharacterCount(movie.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(movie),
),
],
),
),
@@ -934,6 +960,10 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
_buildActorsSection(movie),
if (movie.genres.isNotEmpty)
_buildGenresSection(movie),
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
),
WorkPeopleSection(workId: movie.id, workType: 'movie'),
if (movie.summary != null && movie.summary!.isNotEmpty)
_buildSummarySection(movie),
@@ -1066,7 +1096,11 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
const SizedBox(height: 12),
_buildGenresSection(movie),
],
const SizedBox(height: 12),
CharacterPreviewSection(
characters: _characters,
onTap: _openCharacterSheet,
isOverlay: true,
),
// 关联人物
WorkPeopleSection(workId: movie.id, workType: 'movie'),
const SizedBox(height: 12),
@@ -1202,6 +1236,14 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
foregroundColor: colors.onPrimary,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.people_outline,
onPressed: () => _navigateToCharacters(movie),
tooltip: '角色',
backgroundColor: colors.secondaryContainer,
foregroundColor: colors.onSecondaryContainer,
),
const SizedBox(height: 12),
_buildFloatingButton(
icon: Icons.delete_outline,
onPressed: () => _showDeleteDialog(context),
@@ -1811,6 +1853,15 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
unit: '张海报',
onTap: () => _navigateToPosters(movie),
),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getMovieCharacterCount(movie.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(movie),
),
],
),
);
@@ -1839,6 +1890,15 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
unit: '张海报',
onTap: () => _navigateToPosters(movie),
),
const SizedBox(height: 12),
_buildFrostedExtraItem(
icon: Icons.people_outline,
title: '角色',
subtitleFuture: context.read<AppProvider>().getMovieCharacterCount(movie.id),
emptyText: '暂无角色',
unit: '个角色',
onTap: () => _navigateToCharacters(movie),
),
],
),
);
@@ -1987,6 +2047,25 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
);
}
void _navigateToCharacters(Movie movie) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MovieCharactersPage(movie: movie),
),
).then((_) => _loadCharacters());
}
Future<void> _openCharacterSheet(dynamic character) async {
final needRefresh = await CharacterInfoSheet.show(
context,
entityType: 'movie',
entityId: widget.movie.id,
character: character,
);
if (needRefresh == true) _loadCharacters();
}
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}

View File

@@ -19,6 +19,9 @@ 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/character/movie_character_dao.dart';
import '../data/character/book_character_dao.dart';
import '../data/character/game_character_dao.dart';
import '../data/database_helper.dart';
import '../utils/image_path_helper.dart';
import '../utils/user_prefs.dart';
@@ -44,6 +47,9 @@ class AppProvider extends ChangeNotifier {
final MoviePersonDao _moviePersonDao = MoviePersonDao();
final BookPersonDao _bookPersonDao = BookPersonDao();
final GamePersonDao _gamePersonDao = GamePersonDao();
final MovieCharacterDao _movieCharacterDao = MovieCharacterDao();
final BookCharacterDao _bookCharacterDao = BookCharacterDao();
final GameCharacterDao _gameCharacterDao = GameCharacterDao();
// 数据列表
List<Movie> _movies = [];
List<Book> _books = [];
@@ -1449,4 +1455,78 @@ class AppProvider extends ChangeNotifier {
await loadPeople();
return (newPersons: newPersons, newRelations: newRelations, merged: merged);
}
// ========== 角色相关方法 ==========
// ─── 影视角色 ───
Future<List<MovieCharacter>> getMovieCharacters(String movieId) async {
return await _movieCharacterDao.getByMovieId(movieId);
}
Future<int> getMovieCharacterCount(String movieId) async {
return await _movieCharacterDao.getCount(movieId);
}
Future<void> addMovieCharacter(MovieCharacter character) async {
await _movieCharacterDao.insert(character);
notifyListeners();
}
Future<void> updateMovieCharacter(MovieCharacter character) async {
await _movieCharacterDao.update(character);
notifyListeners();
}
Future<void> deleteMovieCharacter(String id) async {
await _movieCharacterDao.delete(id);
notifyListeners();
}
// ─── 书籍角色 ───
Future<List<BookCharacter>> getBookCharacters(String bookId) async {
return await _bookCharacterDao.getByBookId(bookId);
}
Future<int> getBookCharacterCount(String bookId) async {
return await _bookCharacterDao.getCount(bookId);
}
Future<void> addBookCharacter(BookCharacter character) async {
await _bookCharacterDao.insert(character);
notifyListeners();
}
Future<void> updateBookCharacter(BookCharacter character) async {
await _bookCharacterDao.update(character);
notifyListeners();
}
Future<void> deleteBookCharacter(String id) async {
await _bookCharacterDao.delete(id);
notifyListeners();
}
// ─── 游戏角色 ───
Future<List<GameCharacter>> getGameCharacters(String gameId) async {
return await _gameCharacterDao.getByGameId(gameId);
}
Future<int> getGameCharacterCount(String gameId) async {
return await _gameCharacterDao.getCount(gameId);
}
Future<void> addGameCharacter(GameCharacter character) async {
await _gameCharacterDao.insert(character);
notifyListeners();
}
Future<void> updateGameCharacter(GameCharacter character) async {
await _gameCharacterDao.update(character);
notifyListeners();
}
Future<void> deleteGameCharacter(String id) async {
await _gameCharacterDao.delete(id);
notifyListeners();
}
}

View File

@@ -75,6 +75,11 @@ class BackupService {
final moviePeople = await db.query('movie_people');
final bookPeople = await db.query('book_people');
final gamePeople = await db.query('game_people');
final playlists = await db.query('playlists');
final playlistItems = await db.query('playlist_items');
final movieCharacters = await db.query('movie_characters');
final bookCharacters = await db.query('book_characters');
final gameCharacters = await db.query('game_characters');
// 收集图片路径
final imagePaths = <String>{};
@@ -117,6 +122,10 @@ class BackupService {
final pp = p['photo_path'] as String?;
if (pp != null && pp.isNotEmpty) imagePaths.add(pp);
}
for (final c in [...movieCharacters, ...bookCharacters, ...gameCharacters]) {
final ip = c['image_path'] as String?;
if (ip != null && ip.isNotEmpty) imagePaths.add(ip);
}
final userPrefs = UserPrefs();
final userInfo = {
@@ -153,6 +162,11 @@ class BackupService {
'movie_people': moviePeople,
'book_people': bookPeople,
'game_people': gamePeople,
'playlists': playlists,
'playlist_items': playlistItems,
'movie_characters': movieCharacters,
'book_characters': bookCharacters,
'game_characters': gameCharacters,
},
};
@@ -436,6 +450,11 @@ class BackupService {
final moviePeopleCols = await _getTableColumns(db, 'movie_people');
final bookPeopleCols = await _getTableColumns(db, 'book_people');
final gamePeopleCols = await _getTableColumns(db, 'game_people');
final playlistsCols = await _getTableColumns(db, 'playlists');
final playlistItemsCols = await _getTableColumns(db, 'playlist_items');
final movieCharactersCols = await _getTableColumns(db, 'movie_characters');
final bookCharactersCols = await _getTableColumns(db, 'book_characters');
final gameCharactersCols = await _getTableColumns(db, 'game_characters');
await db.transaction((txn) async {
await txn.delete('movie_reviews');
@@ -449,6 +468,11 @@ class BackupService {
await txn.delete('book_people');
await txn.delete('game_people');
await txn.delete('people');
await txn.delete('playlist_items');
await txn.delete('playlists');
await txn.delete('movie_characters');
await txn.delete('book_characters');
await txn.delete('game_characters');
await txn.delete('movies');
await txn.delete('books');
await txn.delete('notes');
@@ -527,6 +551,51 @@ class BackupService {
);
}
}
if (data.containsKey('people')) {
for (final p in data['people'] as List) {
await txn.insert('people', _updateImagePath(_convertToDbMapSafe(p, peopleCols), 'photo_path', imagePathMap));
}
}
if (data.containsKey('movie_people')) {
for (final mp in data['movie_people'] as List) {
await txn.insert('movie_people', _convertToDbMapSafe(mp, moviePeopleCols));
}
}
if (data.containsKey('book_people')) {
for (final bp in data['book_people'] as List) {
await txn.insert('book_people', _convertToDbMapSafe(bp, bookPeopleCols));
}
}
if (data.containsKey('game_people')) {
for (final gp in data['game_people'] as List) {
await txn.insert('game_people', _convertToDbMapSafe(gp, gamePeopleCols));
}
}
if (data.containsKey('playlists')) {
for (final pl in data['playlists'] as List) {
await txn.insert('playlists', _updateImagePath(_convertToDbMapSafe(pl, playlistsCols), 'cover_path', imagePathMap));
}
}
if (data.containsKey('playlist_items')) {
for (final pi in data['playlist_items'] as List) {
await txn.insert('playlist_items', _convertToDbMapSafe(pi, playlistItemsCols));
}
}
if (data.containsKey('movie_characters')) {
for (final c in data['movie_characters'] as List) {
await txn.insert('movie_characters', _updateImagePath(_convertToDbMapSafe(c, movieCharactersCols), 'image_path', imagePathMap));
}
}
if (data.containsKey('book_characters')) {
for (final c in data['book_characters'] as List) {
await txn.insert('book_characters', _updateImagePath(_convertToDbMapSafe(c, bookCharactersCols), 'image_path', imagePathMap));
}
}
if (data.containsKey('game_characters')) {
for (final c in data['game_characters'] as List) {
await txn.insert('game_characters', _updateImagePath(_convertToDbMapSafe(c, gameCharactersCols), 'image_path', imagePathMap));
}
}
});
// 恢复用户信息
@@ -600,6 +669,11 @@ class BackupService {
final moviePeopleCols = await _getTableColumns(db, 'movie_people');
final bookPeopleCols = await _getTableColumns(db, 'book_people');
final gamePeopleCols = await _getTableColumns(db, 'game_people');
final playlistsCols = await _getTableColumns(db, 'playlists');
final playlistItemsCols = await _getTableColumns(db, 'playlist_items');
final movieCharactersCols = await _getTableColumns(db, 'movie_characters');
final bookCharactersCols = await _getTableColumns(db, 'book_characters');
final gameCharactersCols = await _getTableColumns(db, 'game_characters');
await db.transaction((txn) async {
await txn.delete('movie_reviews');
@@ -613,6 +687,11 @@ class BackupService {
await txn.delete('book_people');
await txn.delete('game_people');
await txn.delete('people');
await txn.delete('playlist_items');
await txn.delete('playlists');
await txn.delete('movie_characters');
await txn.delete('book_characters');
await txn.delete('game_characters');
await txn.delete('movies');
await txn.delete('books');
await txn.delete('notes');
@@ -691,6 +770,51 @@ class BackupService {
);
}
}
if (data.containsKey('people')) {
for (final p in data['people'] as List) {
await txn.insert('people', _updateImagePath(_convertToDbMapSafe(p, peopleCols), 'photo_path', imagePathMap));
}
}
if (data.containsKey('movie_people')) {
for (final mp in data['movie_people'] as List) {
await txn.insert('movie_people', _convertToDbMapSafe(mp, moviePeopleCols));
}
}
if (data.containsKey('book_people')) {
for (final bp in data['book_people'] as List) {
await txn.insert('book_people', _convertToDbMapSafe(bp, bookPeopleCols));
}
}
if (data.containsKey('game_people')) {
for (final gp in data['game_people'] as List) {
await txn.insert('game_people', _convertToDbMapSafe(gp, gamePeopleCols));
}
}
if (data.containsKey('playlists')) {
for (final pl in data['playlists'] as List) {
await txn.insert('playlists', _updateImagePath(_convertToDbMapSafe(pl, playlistsCols), 'cover_path', imagePathMap));
}
}
if (data.containsKey('playlist_items')) {
for (final pi in data['playlist_items'] as List) {
await txn.insert('playlist_items', _convertToDbMapSafe(pi, playlistItemsCols));
}
}
if (data.containsKey('movie_characters')) {
for (final c in data['movie_characters'] as List) {
await txn.insert('movie_characters', _updateImagePath(_convertToDbMapSafe(c, movieCharactersCols), 'image_path', imagePathMap));
}
}
if (data.containsKey('book_characters')) {
for (final c in data['book_characters'] as List) {
await txn.insert('book_characters', _updateImagePath(_convertToDbMapSafe(c, bookCharactersCols), 'image_path', imagePathMap));
}
}
if (data.containsKey('game_characters')) {
for (final c in data['game_characters'] as List) {
await txn.insert('game_characters', _updateImagePath(_convertToDbMapSafe(c, gameCharactersCols), 'image_path', imagePathMap));
}
}
});
await _restoreUserInfo(backupData, imagePathMap);
@@ -778,6 +902,13 @@ class BackupService {
if (data.containsKey('games')) stats['游戏'] = (data['games'] as List).length;
if (data.containsKey('game_reviews')) stats['游戏评价'] = (data['game_reviews'] as List).length;
if (data.containsKey('game_screenshots')) stats['游戏截图'] = (data['game_screenshots'] as List).length;
if (data.containsKey('people')) stats['人物'] = (data['people'] as List).length;
if (data.containsKey('playlists')) stats['片单'] = (data['playlists'] as List).length;
int charCount = 0;
for (final key in ['movie_characters', 'book_characters', 'game_characters']) {
if (data.containsKey(key)) charCount += (data[key] as List).length;
}
if (charCount > 0) stats['角色'] = charCount;
if (imageCount > 0) stats['图片'] = imageCount;
return stats;
}

View File

@@ -13,6 +13,7 @@ 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';
import '../pages/character/character_form_page.dart';
/// 路由生成器
class AppRouter {
@@ -95,6 +96,16 @@ class AppRouter {
final Person? person = args is Person ? args : null;
return SlideUpPageRoute(page: PersonFormPage(person: person));
case '/character-form':
final args = settings.arguments as Map<String, dynamic>;
return SlideUpPageRoute(
page: CharacterFormPage(
entityType: args['entityType'] as String,
entityId: args['entityId'] as String,
character: args['character'],
),
);
default:
return _buildUnknownRoute(settings.name);
}

View File

@@ -147,6 +147,29 @@ class ImagePathHelper {
return p.join(dir, fileName);
}
// ==================== 角色相关路径 ====================
/// 获取角色图片目录
/// 路径: images/characters/{characterId}/
Future<String> getCharacterImagesDir(String characterId) async {
final root = await imagesRoot;
return p.join(root, 'characters', characterId);
}
/// 获取角色图片路径
/// 路径: images/characters/{characterId}/{fileName}
Future<String> getCharacterImagePath(String characterId, String fileName) async {
final dir = await getCharacterImagesDir(characterId);
return p.join(dir, fileName);
}
/// 删除角色图片目录
/// 删除路径: images/characters/{characterId}/
Future<void> deleteCharacterImages(String characterId) async {
final dirPath = await getCharacterImagesDir(characterId);
await _deleteDirectory(dirPath);
}
// ==================== 目录操作 ====================
/// 确保目录存在

View File

@@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
import '../pages/character/character_form_page.dart';
import 'fade_in_local_image.dart';
/// 角色信息底部弹窗
///
/// 展示角色详情,提供编辑入口。
/// [entityType] = 'movie' / 'book' / 'game'
/// [entityId] = 所属作品 ID
/// [character] = MovieCharacter / BookCharacter / GameCharacter
class CharacterInfoSheet extends StatefulWidget {
final String entityType;
final String entityId;
final dynamic character;
const CharacterInfoSheet({
super.key,
required this.entityType,
required this.entityId,
required this.character,
});
static Future<bool?> show(
BuildContext context, {
required String entityType,
required String entityId,
required dynamic character,
}) {
return showModalBottomSheet<bool>(
context: context,
backgroundColor: Theme.of(context).colorScheme.surface,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => CharacterInfoSheet(
entityType: entityType,
entityId: entityId,
character: character,
),
);
}
@override
State<CharacterInfoSheet> createState() => _CharacterInfoSheetState();
}
class _CharacterInfoSheetState extends State<CharacterInfoSheet> {
bool _summaryExpanded = false;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final c = widget.character;
final name = c.name as String;
final role = c.role as String?;
final aliases = c.aliases as List<String>;
final tags = c.tags as List<String>;
final description = c.description as String?;
final imagePath = c.imagePath as String?;
final maxHeight = MediaQuery.of(context).size.height * 0.7;
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(name, role, imagePath, colors),
const SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (aliases.isNotEmpty)
_buildInfoRow('别名', aliases.join(''), colors),
if (tags.isNotEmpty)
_buildInfoRow('标签', tags.join(' | '), colors),
if (description != null && description.isNotEmpty) ...[
const SizedBox(height: 12),
_buildSectionTitle('简介', colors),
const SizedBox(height: 8),
_buildSummary(description, colors),
],
],
),
),
),
],
),
),
),
);
}
Widget _buildHeader(String name, String? role, String? imagePath, ColorScheme colors) {
final hasImage = imagePath != null && imagePath.isNotEmpty;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
clipBehavior: Clip.antiAlias,
child: hasImage
? FadeInLocalImage(path: imagePath, fit: BoxFit.cover)
: Center(
child: Text(
name.isNotEmpty ? name.characters.first : '?',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: colors.onSurface.withValues(alpha: 0.3),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (role != null && role.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
role,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
TextButton.icon(
onPressed: () async {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => CharacterFormPage(
entityType: widget.entityType,
entityId: widget.entityId,
character: widget.character,
),
),
);
if (result == true && mounted) {
Navigator.pop(context, true);
}
},
icon: const Icon(Icons.edit_outlined, 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: 56,
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),
),
),
],
],
);
}
}

View File

@@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
import 'fade_in_local_image.dart';
/// 角色卡片横向预览组件
///
/// 在影视/书籍/游戏详情页的角色入口上方展示。
/// 空列表返回 SizedBox.shrink(),不占空间。
class CharacterPreviewSection extends StatelessWidget {
final List<dynamic> characters;
final void Function(dynamic character) onTap;
final bool isOverlay;
const CharacterPreviewSection({
super.key,
required this.characters,
required this.onTap,
this.isOverlay = false,
});
@override
Widget build(BuildContext context) {
if (characters.isEmpty) return const SizedBox.shrink();
final colors = Theme.of(context).colorScheme;
final titleColor = isOverlay ? Colors.white : colors.onSurface;
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: titleColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
'角色',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: titleColor,
),
),
],
),
const SizedBox(height: 20),
ShaderMask(
shaderCallback: (Rect bounds) {
return const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0x00FFFFFF),
Color(0xFFFFFFFF),
Color(0xFFFFFFFF),
Color(0x00FFFFFF),
],
stops: [0.0, 0.04, 0.96, 1.0],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: SizedBox(
height: 132,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
itemCount: characters.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
return _CharacterCard(
character: characters[index],
onTap: () => onTap(characters[index]),
isOverlay: isOverlay,
);
},
),
),
),
],
),
);
}
}
class _CharacterCard extends StatelessWidget {
final dynamic character;
final VoidCallback onTap;
final bool isOverlay;
const _CharacterCard({
required this.character,
required this.onTap,
required this.isOverlay,
});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final name = character.name as String;
final role = character.role as String?;
final aliases = character.aliases as List<String>;
final tags = character.tags as List<String>;
final description = character.description as String?;
final imagePath = character.imagePath as String?;
final cardColor = isOverlay
? Colors.white.withValues(alpha: 0.08)
: colors.surfaceContainerHigh;
final borderColor = isOverlay
? Colors.white.withValues(alpha: 0.12)
: colors.outlineVariant;
final primaryText = isOverlay ? Colors.white : colors.onSurface;
final secondaryText = isOverlay
? Colors.white.withValues(alpha: 0.5)
: colors.onSurface.withValues(alpha: 0.4);
final tagText = isOverlay
? Colors.white.withValues(alpha: 0.7)
: colors.onSurface.withValues(alpha: 0.6);
final avatarBg = isOverlay
? Colors.white.withValues(alpha: 0.1)
: colors.surfaceContainerHighest;
return GestureDetector(
onTap: onTap,
child: Container(
width: 220,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: borderColor, width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 第一行:头像 + 名称 + 角色定位
Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: avatarBg,
shape: BoxShape.circle,
),
clipBehavior: Clip.antiAlias,
child: imagePath != null && imagePath.isNotEmpty
? FadeInLocalImage(path: imagePath, fit: BoxFit.cover)
: Center(
child: Text(
name.isNotEmpty ? name.characters.first : '?',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: secondaryText,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: primaryText,
),
),
if (role != null && role.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 1),
child: Text(
role,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: secondaryText),
),
),
],
),
),
],
),
// 第二行:标签用 | 分割
if (tags.isNotEmpty || aliases.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
[...tags, ...aliases].join(' | '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: tagText, height: 1.3),
),
),
// 第三行:简介,最多两行
if (description != null && description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: secondaryText, height: 1.35),
),
),
],
),
),
);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/data_models.dart';
@@ -88,8 +89,14 @@ class _WorkPeopleSectionState extends State<WorkPeopleSection> {
break;
}
// 按 sortOrder 保留首次出现的顺序
final items = byPerson.values.toList();
// 按角色权重排序:导演/编剧/作者等优先,纯演员最后
// 每个人取其所有角色中的最小权重(最高优先级)作为排序依据
final items = byPerson.values.toList()
..sort((a, b) {
final aWeight = a.roleTypes.map(_roleWeight).reduce(min);
final bWeight = b.roleTypes.map(_roleWeight).reduce(min);
return aWeight.compareTo(bWeight);
});
if (!mounted) return;
setState(() {
_items = items;
@@ -182,16 +189,32 @@ class _WorkPeopleSectionState extends State<WorkPeopleSection> {
],
),
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(),
ShaderMask(
shaderCallback: (Rect bounds) {
return const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0x00FFFFFF),
Color(0xFFFFFFFF),
Color(0xFFFFFFFF),
Color(0x00FFFFFF),
],
stops: [0.0, 0.04, 0.96, 1.0],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: 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(),
),
),
),
],