From 5aaa6b8417fd275dd97315a33f00d27267a293ab Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Thu, 13 Aug 2026 15:25:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=95=AA=E8=8C=84=E9=98=85?= =?UTF-8?q?=E8=AF=BB=E9=93=BE=E6=8E=A5=E6=B7=BB=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/book/book_form_page.dart | 25 +- lib/pages/game/game_form_page.dart | 22 +- lib/pages/movies/douban_webview_page.dart | 366 +++++++++++++++--- lib/pages/movies/movie_form_page.dart | 229 ++---------- lib/pages/profile/profile_page.dart | 17 +- lib/pages/quick_add/quick_add_page.dart | 433 ++++++++++++++++++++++ lib/utils/app_router.dart | 33 +- 7 files changed, 852 insertions(+), 273 deletions(-) create mode 100644 lib/pages/quick_add/quick_add_page.dart diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index 8cda961..5833ab0 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -29,7 +29,10 @@ class BookFormPage extends StatefulWidget { final Book? book; final String? initialStatus; - const BookFormPage({super.key, this.book, this.initialStatus}); + /// 快捷添加预填充字段(book 为 null 时生效,保持添加模式) + final Map? prefill; + + const BookFormPage({super.key, this.book, this.initialStatus, this.prefill}); @override State createState() => _BookFormPageState(); @@ -91,6 +94,26 @@ class _BookFormPageState extends State { } else if (widget.initialStatus != null) { _status = widget.initialStatus!; } + + // 快捷添加预填充(保持添加模式) + if (book == null && widget.prefill != null) { + _fillFromPrefill(widget.prefill!); + } + } + + /// 从快捷添加的预填充 map 填充字段 + void _fillFromPrefill(Map prefill) { + _titleController.text = prefill['title']?.toString() ?? ''; + _publisherController.text = prefill['publisher']?.toString() ?? ''; + _summaryController.text = prefill['summary']?.toString() ?? ''; + _ratingController.text = prefill['rating']?.toString() ?? ''; + _isbnController.text = prefill['isbn']?.toString() ?? ''; + _authors = List.from(prefill['authors'] ?? const []); + _translators = List.from(prefill['translators'] ?? const []); + _alternateTitles = List.from(prefill['alternateTitles'] ?? const []); + _genres = List.from(prefill['genres'] ?? const []); + _coverPath = prefill['coverPath'] as String?; + _publishDate = prefill['publishDate'] as DateTime?; } @override diff --git a/lib/pages/game/game_form_page.dart b/lib/pages/game/game_form_page.dart index 2f19a4c..6359f87 100644 --- a/lib/pages/game/game_form_page.dart +++ b/lib/pages/game/game_form_page.dart @@ -28,7 +28,10 @@ class GameFormPage extends StatefulWidget { final Game? game; final String? initialStatus; - const GameFormPage({super.key, this.game, this.initialStatus}); + /// 快捷添加预填充字段(game 为 null 时生效,保持添加模式) + final Map? prefill; + + const GameFormPage({super.key, this.game, this.initialStatus, this.prefill}); @override State createState() => _GameFormPageState(); @@ -98,6 +101,23 @@ class _GameFormPageState extends State { } else if (widget.initialStatus != null) { _status = widget.initialStatus!; } + + // 快捷添加预填充(保持添加模式) + if (game == null && widget.prefill != null) { + _fillFromPrefill(widget.prefill!); + } + } + + /// 从快捷添加的预填充 map 填充字段 + void _fillFromPrefill(Map prefill) { + _titleController.text = prefill['title']?.toString() ?? ''; + _ratingController.text = prefill['rating']?.toString() ?? ''; + _summaryController.text = prefill['summary']?.toString() ?? ''; + _platforms = List.from(prefill['platforms'] ?? const []); + _genres = List.from(prefill['genres'] ?? const []); + _developer = List.from(prefill['developer'] ?? const []); + _coverPath = prefill['coverPath'] as String?; + _releaseDate = prefill['releaseDate'] as DateTime?; } @override diff --git a/lib/pages/movies/douban_webview_page.dart b/lib/pages/movies/douban_webview_page.dart index ecacf9c..95f7c3d 100644 --- a/lib/pages/movies/douban_webview_page.dart +++ b/lib/pages/movies/douban_webview_page.dart @@ -3,11 +3,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import '../../widgets/app_overlay.dart'; -/// 豆瓣影视WebView页面 - 用于抓取影视信息 +/// 豆瓣WebView页面 - 用于抓取 影视/书籍/游戏 信息 class DoubanWebViewPage extends StatefulWidget { final String url; - const DoubanWebViewPage({super.key, required this.url}); + /// 分类:movie / book / game + final String category; + + /// 来源:douban / fanqie(番茄小说) + final String source; + + const DoubanWebViewPage({ + super.key, + required this.url, + this.category = 'movie', + this.source = 'douban', + }); @override State createState() => _DoubanWebViewPageState(); @@ -18,13 +29,22 @@ class _DoubanWebViewPageState extends State { bool _isLoading = true; bool _isExtracting = false; // 防止重复提取 + String _titleFor() { + if (widget.source == 'fanqie') return '番茄阅读'; + return switch (widget.category) { + 'book' => '豆瓣书籍', + 'game' => '豆瓣游戏', + _ => '豆瓣影视', + }; + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return Scaffold( backgroundColor: colors.surface, appBar: AppBar( - title: const Text('豆瓣影视'), + title: Text(_titleFor()), leading: _buildBackButton(), actions: [ // 提取按钮 - 始终显示 @@ -135,8 +155,14 @@ class _DoubanWebViewPageState extends State { /// 显示提取的信息对话框 Future _showExtractedInfo() async { // 先提取信息 - final movieInfo = await _extractMovieInfo(); - if (movieInfo == null) return; + final info = await _extractInfo(); + if (info == null) return; + + final secondaryLabel = switch (widget.category) { + 'book' => '作者', + 'game' => '开发商', + _ => '导演', + }; // 显示提取的信息 if (mounted) { @@ -144,11 +170,29 @@ class _DoubanWebViewPageState extends State { context: context, builder: (ctx) { final colors = Theme.of(ctx).colorScheme; + final isFanqie = widget.source == 'fanqie'; + final rows = isFanqie + ? [ + _buildInfoRow(colors, '书名', info['title']?.toString() ?? '未提取到'), + _buildInfoRow(colors, '作者', info['author']?.toString() ?? '未提取到'), + _buildInfoRow(colors, '类型', info['genres']?.toString() ?? '未提取到'), + if (info['summary'] != null) + _buildInfoRow(colors, '简介', _truncate(info['summary'])), + ] + : [ + _buildInfoRow(colors, '标题', info['title']?.toString() ?? '未提取到'), + _buildInfoRow(colors, '评分', info['rating']?.toString() ?? '未提取到'), + _buildInfoRow(colors, secondaryLabel, info['director']?.toString() ?? '未提取到'), + _buildInfoRow(colors, '类型', info['genres']?.toString() ?? '未提取到'), + _buildInfoRow(colors, '日期', info['releaseDate']?.toString() ?? '未提取到'), + if (info['summary'] != null) + _buildInfoRow(colors, '简介', _truncate(info['summary'])), + ]; return AlertDialog( backgroundColor: colors.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), title: Text( - '提取的影视信息', + '提取的信息', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, @@ -159,22 +203,7 @@ class _DoubanWebViewPageState extends State { child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInfoRow(colors, '标题', movieInfo['title']?.toString() ?? '未提取到'), - _buildInfoRow(colors, '导演', movieInfo['director']?.toString() ?? '未提取到'), - _buildInfoRow(colors, '类型', movieInfo['genres']?.toString() ?? '未提取到'), - _buildInfoRow(colors, '上映日期', movieInfo['releaseDate']?.toString() ?? '未提取到'), - if (movieInfo['summary'] != null) - _buildInfoRow( - colors, - '简介', - movieInfo['summary'].toString().substring( - 0, - movieInfo['summary'].toString().length > 100 - ? 100 - : movieInfo['summary'].toString().length) + - '...'), - ], + children: rows, ), ), actions: [ @@ -188,7 +217,7 @@ class _DoubanWebViewPageState extends State { TextButton( onPressed: () { Navigator.pop(ctx); - Navigator.pop(context, movieInfo); + Navigator.pop(context, info); }, child: Text( '使用此信息', @@ -234,8 +263,8 @@ class _DoubanWebViewPageState extends State { ); } - /// 提取影视信息 - Future?> _extractMovieInfo() async { + /// 提取信息(按分类选择抓取脚本) + Future?> _extractInfo() async { // 检查是否已提取过,避免重复点击 if (_isExtracting || _controller == null) return null; @@ -252,7 +281,56 @@ class _DoubanWebViewPageState extends State { ); // 执行JavaScript代码提取页面信息 - final result = await _controller!.evaluateJavascript(source: r''' + final result = await _controller!.evaluateJavascript(source: _scriptFor(widget.source)); + + // 关闭加载提示 + if (mounted) Navigator.pop(context); + + // 解析提取的信息 + // evaluateJavascript 返回 JS 值,JSON.stringify 的结果是字符串 + final String jsonStr = result?.toString() ?? ''; + if (jsonStr.isEmpty) return null; + + // result 是 JSON.stringify 的输出,可能带外层引号 + final String cleanJson = jsonStr.startsWith('"') && jsonStr.endsWith('"') + ? jsonDecode(jsonStr) as String + : jsonStr; + final Map movieInfo = jsonDecode(cleanJson) as Map; + + return movieInfo; + } catch (e) { + // 关闭加载提示 + if (mounted) Navigator.pop(context); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('提取信息失败: $e')), + ); + } + return null; + } finally { + _isExtracting = false; + } + } +/// 截断长文本用于信息预览 + String _truncate(Object? v) { + final s = v?.toString() ?? ''; + return s.length > 100 ? '${s.substring(0, 100)}...' : s; + } +} + +/// 按来源/分类返回对应的抓取脚本 +String _scriptFor(String source) { + return switch (source) { + 'fanqie' => _fanqieScript, + 'book' => _bookScript, + 'game' => _gameScript, + _ => _movieScript, + }; +} + +/// 影视抓取脚本(移动版豆瓣 subject 页) +const String _movieScript = r''' (function() { const info = {}; @@ -283,7 +361,9 @@ class _DoubanWebViewPageState extends State { } // 获取评分 - 移动版可能在 mark-item 中 - const ratingEl = document.querySelector('.rating-num') || document.querySelector('.score'); + const ratingEl = document.querySelector('.score-num') + || document.querySelector('.rating-num') + || document.querySelector('.score'); info.rating = ratingEl ? ratingEl.textContent.trim() : ''; // 获取导演 - 从演职员列表中找 @@ -365,35 +445,213 @@ class _DoubanWebViewPageState extends State { return JSON.stringify(info); })() - '''); + '''; - // 关闭加载提示 - if (mounted) Navigator.pop(context); +/// 书籍抓取脚本(豆瓣 subject 页,兼容移动版/网页版) +const String _bookScript = r''' + (function() { + const info = {}; - // 解析提取的信息 - // evaluateJavascript 返回 JS 值,JSON.stringify 的结果是字符串 - final String jsonStr = result?.toString() ?? ''; - if (jsonStr.isEmpty) return null; + // 标题(多种结构回退) + const titleEl = document.querySelector('.sub-title') + || document.querySelector('h1[property="v:itemreviewed"]') + || document.querySelector('.title h1') + || document.querySelector('h1'); + info.title = titleEl ? titleEl.textContent.trim() : ''; - // result 是 JSON.stringify 的输出,可能带外层引号 - final String cleanJson = jsonStr.startsWith('"') && jsonStr.endsWith('"') - ? jsonDecode(jsonStr) as String - : jsonStr; - final Map movieInfo = jsonDecode(cleanJson) as Map; + // 封面(多种结构回退) + const coverEl = document.querySelector('.sub-cover img') + || document.querySelector('#mainpic img') + || document.querySelector('.nbg img') + || document.querySelector('.pic img'); + if (coverEl) { + let coverUrl = coverEl.src; + if (coverUrl && coverUrl.includes('.webp')) { + coverUrl = coverUrl.replace('.webp', '.jpg'); + } + info.coverUrl = coverUrl; + } else { + info.coverUrl = ''; + } - return movieInfo; - } catch (e) { - // 关闭加载提示 - if (mounted) Navigator.pop(context); + // 评分 + const ratingEl = document.querySelector('.score-num') + || document.querySelector('.rating-num') + || document.querySelector('.score') + || document.querySelector('.rating_self strong') + || document.querySelector('.ll.rating_num'); + info.rating = ratingEl ? ratingEl.textContent.trim() : ''; - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('提取信息失败: $e')), - ); - } - return null; - } finally { - _isExtracting = false; - } - } -} + // 简介(网页版为 section-intro_desc,另加多级回退) + const summaryEl = document.querySelector('.section-intro_desc') + || document.querySelector('.subject-intro p') + || document.querySelector('#link-report .intro') + || document.querySelector('.intro'); + info.summary = summaryEl ? summaryEl.textContent.trim().substring(0, 1000) : ''; + + // 副标题/别名 + const originalTitleEl = document.querySelector('.sub-original-title') + || document.querySelector('h2'); + info.alternateTitles = originalTitleEl ? [originalTitleEl.textContent.trim()] : []; + + // 元信息:作者 / 译者 / 出版社 / 出版日期 / ISBN / 类型 + const metaEl = document.querySelector('.sub-meta') + || document.querySelector('#info') + || document.querySelector('.pub'); + const metaText = metaEl ? metaEl.textContent.replace(/\s+/g, ' ').trim() : ''; + info.director = metaText; // 暂存整行,供解析 + info.author = ''; + info.translator = ''; + info.publisher = ''; + info.releaseDate = ''; + info.isbn = ''; + info.genres = ''; + + if (metaText) { + // 作者 + const authorMatch = metaText.match(/作者[:\s]*([^\/\n]+?)(?:\s*译者|\s*出版社|\s*出版年|\s*页数|\s*定价|\s*装帧|\s*丛书|\s*ISBN|$)/); + if (authorMatch) info.author = authorMatch[1].trim(); + + // 译者 + const translatorMatch = metaText.match(/译者[:\s]*([^\/\n]+?)(?:\s*出版社|\s*出版年|\s*页数|\s*定价|\s*装帧|\s*丛书|\s*ISBN|$)/); + if (translatorMatch) info.translator = translatorMatch[1].trim(); + + // 出版社 + const publisherMatch = metaText.match(/出版社[:\s]*([^\/\n]+?)(?:\s*出版年|\s*页数|\s*定价|\s*装帧|\s*丛书|\s*ISBN|$)/); + if (publisherMatch) info.publisher = publisherMatch[1].trim(); + + // 出版日期 / 年份 + const dateMatch = metaText.match(/\d{4}-\d{1,2}(-\d{1,2})?/); + if (dateMatch) info.releaseDate = dateMatch[0]; + + // ISBN + const isbnMatch = metaText.match(/ISBN[:\s]*([\dXx-]+)/); + if (isbnMatch) info.isbn = isbnMatch[1].trim(); + + // 类型标签 + const tagEls = document.querySelectorAll('.sub-tags a, .tags a, .tagCrumb a'); + const tags = []; + tagEls.forEach(el => { + const t = el.textContent.trim(); + if (t) tags.push(t); + }); + info.genres = tags.join(','); + } + + return JSON.stringify(info); + })() + '''; + +/// 游戏抓取脚本(豆瓣 game subject 页 card 结构) +const String _gameScript = r''' + (function() { + const info = {}; + + // 标题:card 内的 title + const titleEl = document.querySelector('.card h1.title') + || document.querySelector('h1.title') + || document.querySelector('.sub-title') + || document.querySelector('h1[property="v:itemreviewed"]'); + info.title = titleEl ? titleEl.textContent.trim() : ''; + + // 封面:subject-info 内的 cover 图 + const coverEl = document.querySelector('.subject-info .cover') + || document.querySelector('.sub-cover img') + || document.querySelector('#mainpic img'); + if (coverEl) { + let coverUrl = coverEl.src; + if (coverUrl && coverUrl.includes('.webp')) { + coverUrl = coverUrl.replace('.webp', '.jpg'); + } + info.coverUrl = coverUrl; + } else { + info.coverUrl = ''; + } + + // 评分:subject-info 内 rating 的 strong + const ratingEl = document.querySelector('.subject-info .rating strong') + || document.querySelector('.score-num') + || document.querySelector('.rating-num'); + info.rating = ratingEl ? ratingEl.textContent.trim() : ''; + + // 简介:subject-intro 内 bd 的 p + const summaryEl = document.querySelector('.subject-intro .bd p') + || document.querySelector('.section-intro_desc') + || document.querySelector('.subject-intro p') + || document.querySelector('.intro'); + info.summary = summaryEl ? summaryEl.textContent.trim().substring(0, 1000) : ''; + + // 元信息:subject-info 内 meta(斜杠分隔:类型 / 平台 / 发行日期) + const metaEl = document.querySelector('.subject-info .meta') + || document.querySelector('.sub-meta'); + const metaText = metaEl ? metaEl.textContent.replace(/\s+/g, ' ').trim() : ''; + info.director = metaText; // 暂存整行,供解析 + info.developer = ''; + info.platforms = ''; + info.releaseDate = ''; + info.genres = ''; + + if (metaText) { + // 发行日期(通常在最末尾,如 "2020-07-08 发行") + const dateMatch = metaText.match(/\d{4}-\d{1,2}(-\d{1,2})?/); + if (dateMatch) info.releaseDate = dateMatch[0]; + + // 按 / 切分,过滤空段和日期段 + const parts = metaText.split('/').map(s => s.trim()).filter(s => s && !s.match(/^\d{4}/)); + const platformNames = ['pc','ps4','ps5','psp','psv','ps3','ps2','xbox one','xbox series','xsx','xss','xbox 360','switch','wii u','wii','3ds','nds','nes','snes','steam','epic','itunes','ios','android','google play','itunes store','web','街机','街机盒']; + const genres = []; + const platforms = []; + parts.forEach(p => { + const lower = p.toLowerCase(); + if (platformNames.some(n => lower.includes(n))) { + platforms.push(p); + } else { + genres.push(p); + } + }); + + // 孤立的日期年份(如 "2020")单列给 releaseDate + if (!info.releaseDate) { + const yearMatch = metaText.match(/\b(19|20)\d{2}\b/); + if (yearMatch) info.releaseDate = yearMatch[0] + '-01-01'; + } + + info.genres = genres.join(','); + info.platforms = platforms.join(','); + } + + return JSON.stringify(info); + })() + '''; + +/// 番茄小说抓取脚本(fanjienovel.com 书籍页) +const String _fanqieScript = r''' + (function() { + const info = {}; + const q = (s) => { const el = document.querySelector(s); return el ? el.textContent.trim() : ''; }; + + // 书名 + info.title = q('h1.info-name'); + + // 作者(如 "骁骑校 / 著") + info.author = q('div.info-author'); + + // 封面 + const coverEl = document.querySelector('img.page-header-img'); + info.coverUrl = coverEl ? coverEl.src : ''; + + // 简介 + const summaryEl = document.querySelector('.abstract-content-text p') + || document.querySelector('.abstract-content-text') + || document.querySelector('.abstract-content'); + info.summary = summaryEl ? summaryEl.textContent.trim().substring(0, 1000) : ''; + + // 标签 + const tagEls = document.querySelectorAll('.category-item'); + const genres = []; + tagEls.forEach(el => { const t = el.textContent.trim(); if (t) genres.push(t); }); + info.genres = genres.join(','); + + return JSON.stringify(info); + })() + '''; diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index 077fede..b9e2a9d 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -29,7 +29,10 @@ class MovieFormPage extends StatefulWidget { final Movie? movie; final String? initialStatus; // 添加时的默认状态 - const MovieFormPage({super.key, this.movie, this.initialStatus}); + /// 快捷添加预填充字段(movie 为 null 时生效,保持添加模式) + final Map? prefill; + + const MovieFormPage({super.key, this.movie, this.initialStatus, this.prefill}); @override State createState() => _MovieFormPageState(); @@ -99,6 +102,25 @@ class _MovieFormPageState extends State { // 添加模式:使用传入的默认状态 _status = widget.initialStatus!; } + + // 快捷添加预填充(保持添加模式) + if (movie == null && widget.prefill != null) { + _fillFromPrefill(widget.prefill!); + } + } + + /// 从快捷添加的预填充 map 填充字段 + void _fillFromPrefill(Map prefill) { + _titleController.text = prefill['title']?.toString() ?? ''; + _ratingController.text = prefill['rating']?.toString() ?? ''; + _summaryController.text = prefill['summary']?.toString() ?? ''; + _genres = List.from(prefill['genres'] ?? const []); + _posterPath = prefill['coverPath'] as String?; + _releaseDate = prefill['releaseDate'] as DateTime?; + _directors = List.from(prefill['directors'] ?? const []); + _writers = List.from(prefill['writers'] ?? const []); + _actors = List.from(prefill['actors'] ?? const []); + _alternateTitles = List.from(prefill['alternateTitles'] ?? const []); } @override @@ -143,204 +165,6 @@ class _MovieFormPageState extends State { ); } - /// 显示快捷添加对话框 - void _showQuickAddDialog() { - final textController = TextEditingController(); - - appDialog( - context: context, - builder: (dialogContext) { - final colors = Theme.of(dialogContext).colorScheme; - return AlertDialog( - backgroundColor: colors.surface, - 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: textController, - autofocus: true, - decoration: InputDecoration( - hintText: 'https://m.douban.com/subject/...', - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: colors.primary, width: 1.5), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - onSubmitted: (url) async { - if (url.trim().isNotEmpty) { - // 先移除焦点 - FocusManager.instance.primaryFocus?.unfocus(); - await Future.delayed(const Duration(milliseconds: 50)); - if (dialogContext.mounted) { - Navigator.of(dialogContext).pop(url); - } - } - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () async { - // 先移除焦点,等待一帧确保焦点已释放 - FocusManager.instance.primaryFocus?.unfocus(); - await Future.delayed(const Duration(milliseconds: 50)); - if (dialogContext.mounted) { - Navigator.of(dialogContext).pop(null); - } - }, - child: Text( - '取消', - style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)), - ), - ), - TextButton( - onPressed: () async { - final url = textController.text.trim(); - if (url.isNotEmpty) { - // 先移除焦点 - FocusManager.instance.primaryFocus?.unfocus(); - await Future.delayed(const Duration(milliseconds: 50)); - if (dialogContext.mounted) { - Navigator.of(dialogContext).pop(url); - } - } - }, - child: Text( - '确定', - style: TextStyle(color: colors.onSurface, fontWeight: FontWeight.w600), - ), - ), - ], - ); - }, - ).then((result) { - // 延迟 dispose,确保 widget tree 已释放 controller - WidgetsBinding.instance.addPostFrameCallback((_) { - textController.dispose(); - }); - // 处理结果 - if (result != null && result is String && result.isNotEmpty) { - _openDoubanWebView(result); - } - }); - } - - /// 打开豆瓣WebView页面 - Future _openDoubanWebView(String url) async { - // 导航到WebView页面并等待返回结果 - final result = await Navigator.pushNamed( - context, - '/douban-webview', - arguments: url, - ); - - if (!mounted) return; - // 处理返回的影视信息 - if (result != null && result is Map) { - _fillMovieInfo(result); - } - } - - /// 填充影视信息到表单 - void _fillMovieInfo(Map info) { - setState(() { - // 填充标题 - if (info['title'] != null && info['title'].toString().isNotEmpty) { - _titleController.text = info['title'].toString(); - } - - // 填充评分 - if (info['rating'] != null && info['rating'].toString().isNotEmpty) { - _ratingController.text = info['rating'].toString(); - } - - // 填充导演 - if (info['director'] != null && info['director'].toString().isNotEmpty) { - _directors = [info['director'].toString()]; - } - - // 填充编剧 - if (info['writers'] != null && info['writers'] is List) { - _writers = (info['writers'] as List).map((w) => w.toString()).toList(); - } - - // 填充演员 - if (info['actors'] != null && info['actors'] is List) { - _actors = (info['actors'] as List).map((a) => a.toString()).toList(); - } - - // 填充类型 - if (info['genres'] != null && info['genres'].toString().isNotEmpty) { - _genres = info['genres'].toString().split(',').map((g) => g.trim()).toList(); - } - - // 填充别名 - if (info['alternateTitles'] != null && info['alternateTitles'] is List) { - _alternateTitles = (info['alternateTitles'] as List).map((t) => t.toString()).toList(); - } - - // 填充简介 - if (info['summary'] != null && info['summary'].toString().isNotEmpty) { - _summaryController.text = info['summary'].toString(); - } - - // 填充上映日期 - if (info['releaseDate'] != null && info['releaseDate'].toString().isNotEmpty) { - final dateStr = info['releaseDate'].toString(); - // 尝试解析日期 - try { - // 处理格式如 "2023-01-01(中国大陆)" - final cleanDate = dateStr.split('(')[0].trim(); - _releaseDate = DateTime.parse(cleanDate); - } catch (e) { - // 解析失败则忽略 - } - } - - // 下载封面图 - if (info['coverUrl'] != null && info['coverUrl'].toString().isNotEmpty) { - _downloadCoverFromUrl(info['coverUrl'].toString()); - } - - }); - - // 显示成功提示 - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('已自动填充影视信息')), - ); - } - } - /// 从URL下载封面图 Future _downloadCoverFromUrl(String url) async { setState(() => _isDownloading = true); @@ -392,13 +216,6 @@ class _MovieFormPageState extends State { appBar: AppBar( title: Text(isEdit ? '编辑影视' : '添加影视'), actions: [ - // 快捷添加按钮(仅添加模式显示) - if (!isEdit) - _buildActionButton( - icon: Icons.auto_fix_high_outlined, - onPressed: _showQuickAddDialog, - tooltip: '快捷添加', - ), // 保存按钮 _buildActionButton( icon: Icons.save_outlined, diff --git a/lib/pages/profile/profile_page.dart b/lib/pages/profile/profile_page.dart index 7517807..069e7bf 100644 --- a/lib/pages/profile/profile_page.dart +++ b/lib/pages/profile/profile_page.dart @@ -21,6 +21,7 @@ import '../explore/stroll_page.dart'; import '../sync/cloud_sync_page.dart'; import 'settings_page.dart'; import 'watchlist_page.dart'; +import '../quick_add/quick_add_page.dart'; import '../../widgets/app_overlay.dart'; /// 个人中心页面 @@ -956,12 +957,18 @@ class _ProfilePageState extends State with RouteAware { ), ( Icons.analytics_outlined, - '统计', + '数据统计', () => Navigator.push( context, MaterialPageRoute(builder: (_) => const StatisticsPage())) ), - (Icons.backup_outlined, '备份', () => _showBackupOptions(context)), - (Icons.ios_share_outlined, '导出', () => _showExportOptions(context)), + ( + Icons.add_circle_outline, + '快捷添加', + () => Navigator.push( + context, MaterialPageRoute(builder: (_) => const QuickAddPage())) + ), + (Icons.backup_outlined, '数据备份', () => _showBackupOptions(context)), + (Icons.ios_share_outlined, 'EXCEL导出', () => _showExportOptions(context)), ( Icons.settings_outlined, '设置', @@ -970,11 +977,11 @@ class _ProfilePageState extends State with RouteAware { ), ( Icons.delete_outline, - '回收', + '回收站', () => Navigator.push( context, MaterialPageRoute(builder: (_) => const RecycleBinPage())) ), - (Icons.feedback_outlined, '反馈', () => _showFeedbackDialog(context)), + (Icons.feedback_outlined, 'BUG反馈', () => _showFeedbackDialog(context)), ]; return Padding( diff --git a/lib/pages/quick_add/quick_add_page.dart b/lib/pages/quick_add/quick_add_page.dart new file mode 100644 index 0000000..ea349f1 --- /dev/null +++ b/lib/pages/quick_add/quick_add_page.dart @@ -0,0 +1,433 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../../utils/image_path_helper.dart'; +import '../../utils/toast_util.dart'; + +/// 豆瓣官方 logo(绿色) +const _doubanSvg = ''' + +'''; + +const _doubanColor = Color(0xFF319C4A); +const _fanqieColor = Color(0xFFF44336); + +/// 番茄阅读 logo(红色)— 红色描边轮廓 +const _fanqieSvg = ''' + +'''; + +/// 快捷添加页 — 选择分类 + 输入豆瓣链接,解析后跳转到对应添加表单 +class QuickAddPage extends StatefulWidget { + const QuickAddPage({super.key}); + + @override + State createState() => _QuickAddPageState(); +} + +class _QuickAddPageState extends State { + static const _categories = [ + ('影视', 'movie', Icons.movie_outlined), + ('书籍', 'book', Icons.menu_book_outlined), + ('游戏', 'game', Icons.sports_esports_outlined), + ]; + + String _category = 'movie'; + final _doubanController = TextEditingController(); + final _fanqieController = TextEditingController(); + bool _parsing = false; + bool _doubanExpanded = false; + bool _fanqieExpanded = false; + + @override + void dispose() { + _doubanController.dispose(); + _fanqieController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: const Text('快捷添加'), + backgroundColor: colors.surface, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: SegmentedButton( + segments: _categories + .map((c) => ButtonSegment( + value: c.$2, + icon: Icon(c.$3, size: 18), + )) + .toList(), + selected: {_category}, + onSelectionChanged: (v) => setState(() => _category = v.first), + showSelectedIcon: false, + style: ButtonStyle( + visualDensity: VisualDensity.compact, + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 10)), + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colors.primary; + } + return colors.surfaceContainerHighest.withValues(alpha: 0.5); + }), + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colors.onPrimary; + } + return colors.onSurface.withValues(alpha: 0.6); + }), + side: const WidgetStatePropertyAll(BorderSide.none), + shape: WidgetStatePropertyAll(RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8))), + ), + ), + ), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + _buildSourceCard( + colors: colors, + icon: SizedBox( + width: 20, + height: 20, + child: SvgPicture.string(_doubanSvg, fit: BoxFit.contain)), + iconTileColor: _doubanColor, + title: '豆瓣', + subtitle: '输入豆瓣链接,自动解析并填充信息', + controller: _doubanController, + expanded: _doubanExpanded, + onToggle: () => setState(() => _doubanExpanded = !_doubanExpanded), + onParse: () => _parseDouban(), + ), + if (_category == 'book') ...[ + const SizedBox(height: 12), + _buildSourceCard( + colors: colors, + icon: SizedBox( + width: 20, + height: 20, + child: SvgPicture.string(_fanqieSvg, fit: BoxFit.contain)), + iconTileColor: _fanqieColor, + title: '番茄阅读', + subtitle: '输入番茄小说链接,自动解析并填充信息', + controller: _fanqieController, + expanded: _fanqieExpanded, + onToggle: () => setState(() => _fanqieExpanded = !_fanqieExpanded), + onParse: _parseFanqie, + ), + ], + const SizedBox(height: 12), + Text('点击右侧箭头展开,填入链接后点「解析」,跳转到对应表单', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), + ], + ), + ); + } + + /// 添加来源列表项 — 右侧箭头展开后填入链接解析 + Widget _buildSourceCard({ + required ColorScheme colors, + required Widget icon, + required Color iconTileColor, + required String title, + required String subtitle, + required TextEditingController controller, + required bool expanded, + required VoidCallback onToggle, + required VoidCallback onParse, + }) { + return Container( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outline), + boxShadow: [ + BoxShadow( + color: colors.onSurface.withValues(alpha: 0.018), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + InkWell( + onTap: onToggle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: iconTileColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: icon, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: colors.onSurface)), + const SizedBox(height: 2), + Text(subtitle, + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ), + AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon(Icons.expand_more, + color: colors.onSurface.withValues(alpha: 0.4)), + ), + ], + ), + ), + ), + AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: expanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: const SizedBox(width: double.infinity), + secondChild: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1, thickness: 0.6), + const SizedBox(height: 12), + TextField( + controller: controller, + keyboardType: TextInputType.url, + style: TextStyle(fontSize: 14, color: colors.onSurface), + decoration: InputDecoration( + hintText: 'https://...', + hintStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.25)), + prefixIcon: Icon(Icons.link, size: 18, color: colors.onSurface.withValues(alpha: 0.3)), + filled: true, + fillColor: colors.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: _parsing ? null : onParse, + icon: _parsing + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.auto_fix_high_outlined, size: 18), + label: Text(_parsing ? '解析中...' : '解析'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Future _parseDouban() async { + final url = _doubanController.text.trim(); + if (url.isEmpty) { + ToastUtil.show(context, '请输入豆瓣链接'); + return; + } + FocusManager.instance.primaryFocus?.unfocus(); + setState(() => _parsing = true); + try { + final result = await Navigator.of(context).pushNamed( + '/douban-webview', + arguments: {'url': url, 'category': _category}, + ) as Map?; + if (!mounted || result == null) return; + await _openForm(result); + } finally { + if (mounted) setState(() => _parsing = false); + } + } + + Future _parseFanqie() async { + final url = _fanqieController.text.trim(); + if (url.isEmpty) { + ToastUtil.show(context, '请输入番茄小说链接'); + return; + } + FocusManager.instance.primaryFocus?.unfocus(); + setState(() => _parsing = true); + try { + final result = await Navigator.of(context).pushNamed( + '/douban-webview', + arguments: {'url': url, 'category': 'book', 'source': 'fanqie'}, + ) as Map?; + if (!mounted || result == null) return; + await _openBookFromInfo(result); + } finally { + if (mounted) setState(() => _parsing = false); + } + } + + /// 番茄结果 → 书籍表单预填充 + Future _openBookFromInfo(Map info) async { + final id = const Uuid().v4(); + final authorRaw = info['author']?.toString().trim() ?? ''; + final authors = authorRaw + .replaceAll(RegExp(r'[((]?著[))]?$'), '') + .split(RegExp(r'[/、,,]')) + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + + final coverPath = await _downloadCover(info['coverUrl']?.toString() ?? '', id); + + if (!mounted) return; + + final prefill = { + 'title': info['title']?.toString().trim() ?? '', + 'authors': authors, + 'genres': _splitList(info['genres']), + 'summary': info['summary']?.toString().trim() ?? '', + 'coverPath': coverPath, + }; + Navigator.of(context).pushNamed('/book-form', arguments: {'prefill': prefill}); + } + + Future _openForm(Map info) async { + final id = const Uuid().v4(); + final title = info['title']?.toString().trim() ?? ''; + final rating = double.tryParse(info['rating']?.toString() ?? ''); + final genres = _splitList(info['genres']); + final summary = info['summary']?.toString().trim() ?? ''; + final releaseDate = _parseDate(info['releaseDate']?.toString()); + + // 尽力下载封面,失败不阻塞 + final coverPath = await _downloadCover(info['coverUrl']?.toString() ?? '', id); + + if (!mounted) return; + + // 预填充字段(不传模型,保持表单为「添加」模式) + final prefill = { + 'title': title, + 'rating': rating, + 'genres': genres, + 'summary': summary, + 'releaseDate': releaseDate, + 'coverPath': coverPath, + }; + + switch (_category) { + case 'book': + prefill['authors'] = _splitList(info['author']); + prefill['translators'] = _splitList(info['translator']); + prefill['publisher'] = info['publisher']?.toString().trim() ?? ''; + prefill['isbn'] = info['isbn']?.toString().trim() ?? ''; + prefill['publishDate'] = releaseDate; + Navigator.of(context).pushNamed('/book-form', arguments: {'prefill': prefill}); + case 'game': + prefill['developer'] = _splitList(info['developer']); + prefill['platforms'] = _splitList(info['platforms']); + Navigator.of(context).pushNamed('/game-form', arguments: {'prefill': prefill}); + default: + prefill['directors'] = info['director']?.toString().trim().isNotEmpty == true + ? [info['director'].toString().trim()] + : const []; + prefill['writers'] = (info['writers'] as List?)?.map((e) => e.toString()).toList() ?? const []; + prefill['actors'] = (info['actors'] as List?)?.map((e) => e.toString()).toList() ?? const []; + prefill['alternateTitles'] = (info['alternateTitles'] as List?)?.map((e) => e.toString()).toList() ?? const []; + Navigator.of(context).pushNamed('/movie-form', arguments: {'prefill': prefill}); + } + } + + List _splitList(Object? value) { + if (value == null) return const []; + final s = value.toString(); + if (s.trim().isEmpty) return const []; + return s.split(RegExp(r'[/、,,]')) + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + } + + DateTime? _parseDate(String? s) { + if (s == null || s.trim().isEmpty) return null; + try { + final clean = s.trim().split('(').first.trim(); + return DateTime.parse(clean); + } catch (_) { + return null; + } + } + + /// 下载封面到本地,返回本地路径;失败返回 null + Future _downloadCover(String url, String id) async { + if (url.isEmpty || !url.startsWith('http')) return null; + 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) return null; + final contentType = response.headers['content-type']; + if (contentType != null && !contentType.startsWith('image/')) return null; + + final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final String targetPath; + switch (_category) { + case 'book': + targetPath = await ImagePathHelper.instance.getBookCoverPath(id, fileName); + case 'game': + targetPath = await ImagePathHelper.instance.getGameCoverPath(id, fileName); + default: + targetPath = await ImagePathHelper.instance.getMoviePosterPath(id, fileName); + } + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + await File(targetPath).writeAsBytes(response.bodyBytes); + return targetPath; + } catch (e) { + debugPrint('封面下载失败: $e'); + return null; + } + } +} \ No newline at end of file diff --git a/lib/utils/app_router.dart b/lib/utils/app_router.dart index 32b4d0b..0c39449 100644 --- a/lib/utils/app_router.dart +++ b/lib/utils/app_router.dart @@ -24,8 +24,10 @@ class AppRouter { final Movie? movie = args is Movie ? args : null; final String? initialStatus = args is Map ? (args['initialStatus'] as String?) : null; + final Map? prefill = + args is Map ? (args['prefill'] as Map?) : null; return SlideUpPageRoute( - page: MovieFormPage(movie: movie, initialStatus: initialStatus), + page: MovieFormPage(movie: movie, initialStatus: initialStatus, prefill: prefill), ); case '/book-form': @@ -33,8 +35,10 @@ class AppRouter { final Book? book = args is Book ? args : null; final String? initialStatus = args is Map ? (args['initialStatus'] as String?) : null; + final Map? prefill = + args is Map ? (args['prefill'] as Map?) : null; return SlideUpPageRoute( - page: BookFormPage(book: book, initialStatus: initialStatus), + page: BookFormPage(book: book, initialStatus: initialStatus, prefill: prefill), ); case '/note-form': @@ -68,8 +72,10 @@ class AppRouter { final Game? game = args is Game ? args : null; final String? initialStatus = args is Map ? (args['initialStatus'] as String?) : null; + final Map? prefill = + args is Map ? (args['prefill'] as Map?) : null; return SlideUpPageRoute( - page: GameFormPage(game: game, initialStatus: initialStatus), + page: GameFormPage(game: game, initialStatus: initialStatus, prefill: prefill), ); case '/game-detail': @@ -80,8 +86,22 @@ class AppRouter { return SlideUpPageRoute(page: GameDetailPage(game: game)); case '/douban-webview': - final url = settings.arguments is String ? settings.arguments as String : null; - if (url == null) { + final args = settings.arguments; + final String url; + final String category; + final String source; + if (args is String) { + url = args; + category = 'movie'; + source = 'douban'; + } else if (args is Map) { + url = (args['url'] as String?) ?? ''; + category = (args['category'] as String?) ?? 'movie'; + source = (args['source'] as String?) ?? 'douban'; + } else { + return _buildUnknownRoute(settings.name); + } + if (url.isEmpty) { return _buildUnknownRoute(settings.name); } if (Platform.isWindows) { @@ -89,7 +109,8 @@ class AppRouter { launchUrl(Uri.parse(url)); return null; } - return SlideUpPageRoute(page: DoubanWebViewPage(url: url)); + return SlideUpPageRoute( + page: DoubanWebViewPage(url: url, category: category, source: source)); case '/person-form': final args = settings.arguments;