优化新增人物功能

This commit is contained in:
DelLevin-Home
2026-08-08 22:33:32 +08:00
parent d638497525
commit df8335b187
22 changed files with 4196 additions and 11 deletions

View File

@@ -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),
],

View File

@@ -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),
]),
),

View File

@@ -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),

View 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('删除'),
),
],
),
);
}
}

View 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;
}
}

View 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();
});
}
}

View File

@@ -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);

View File

@@ -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, '已彻底删除');