Skip to content

Commit f683506

Browse files
shadowfish07claude
andauthored
feat: 实现书签新建、分享新建功能 (#73)
* feat: 实现书签分享功能,包含FAB和完整的创建流程 - 新增 createBookmark API 支持 POST /bookmarks 接口 - 实现 AddBookmarkViewModel 遵循 MVVM 架构模式 - 创建 AddBookmarkScreen 包含表单验证和标签选择 - 在所有书签列表页面添加 Material Design 3 FABs - 复用 LabelEditDialog 进行标签编辑 - 支持异步书签创建处理 (状态码 202) - 修复按钮交互问题:统一提交入口和加载状态显示 - 添加完整的单元测试覆盖 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat(bookmark): 优化书签列表FAB滚动行为并重构创建书签API refactor(api): 修改创建书签返回类型为void style: 移除冗余注释并调整滚动阈值 test: 更新mock文件以匹配API变更 * test(ui): 添加滚动控制器提供者和FAB动画的测试用例 * feat(分享): 添加接收分享内容功能 实现从其他应用接收文本和URL分享的功能,包括: - 添加receive_sharing_intent依赖 - 配置AndroidManifest.xml接收分享Intent - 创建ShareIntentService处理分享内容 - 在主布局中显示分享内容悬浮窗 - 升级Java版本至17以支持新功能 * feat(书签): 实现从分享文本中提取URL并自动填充的功能 - 添加处理分享文本的方法,能够提取URL并更新ViewModel - 修改路由逻辑以支持通过query参数传递分享文本 - 改进表单控制器与ViewModel的双向绑定 - 移除不再使用的ShareOverlay组件 - 添加相关测试用例验证URL提取逻辑 * feat(bookmark): 添加AI标签推荐和自动网页内容获取功能 实现基于OpenRouter API的AI标签推荐功能,包括: 1. 新增WebContentService服务用于获取网页内容 2. 新增AiTagRecommendationRepository处理标签推荐逻辑 3. 扩展AddBookmarkViewModel支持自动获取内容和标签推荐 4. 更新UI展示推荐标签和获取状态 5. 添加相关测试用例 * feat(书签): 添加推荐标签消费状态管理 添加_hasConsumedRecommendations状态控制推荐标签显示逻辑 修复URL输入框错误状态显示问题 添加内容获取和标签推荐失败的错误监听 优化推荐标签UI交互逻辑 * Enhances AddBookmarkViewModel tests Improves the AddBookmarkViewModel tests by using Mockito code generation for mock classes, instead of manual mock implementations. Adds tests for AI availability status and URL validation. Refactors the URL validation tests to await asynchronous operations and fixes form clearing logic. These changes improve the reliability and coverage of the AddBookmarkViewModel tests. * feat(ai标签): 添加AI标签设置功能并优化标签推荐逻辑 实现AI标签设置页面,支持选择目标语言 重构标签推荐逻辑,使用系统提示词和JSON格式响应 将翻译设置整合到AI设置页面中 * feat: 添加AI标签目标语言设置功能 - 在设置仓库中添加保存和获取AI标签目标语言的方法 - 实现AI标签设置视图模型,支持语言选择和保存 - 为AI标签推荐仓库添加目标语言支持 - 添加相关单元测试验证功能 - 更新项目规则文档,添加Mockito null safety错误处理说明 * refactor(bookmarks): 重构书签列表存储方式,使用ID列表替代完整模型列表 修改书签视图模型,将直接存储书签模型列表改为存储书签ID列表,通过ID从仓库获取完整模型。这提高了性能并减少了内存占用,同时需要更新相关测试用例以适应新的实现方式。 * refactor(ui): 重构滚动控制器和FAB的共享逻辑 重构LabelEditDialog以支持更灵活的标签选择 简化书签创建API的响应处理 更新测试用例以适应重构后的组件 将ScrollControllerProvider整合到MainLayout中 移除冗余的滚动控制器管理代码 * fix(web_content_service): 完善URL格式验证逻辑 添加对https协议和空主机名的检查,确保URL验证更严格 * refactor(web内容): 引入WebContentRepository替换直接使用WebContentService 将WebContentService的使用重构为通过WebContentRepository接口,遵循仓库模式分离业务逻辑与数据源 更新相关测试和依赖注入配置以适配新的仓库层 * fix: 修复内容获取状态更新逻辑 将_isContentFetched状态更新移到标题更新之前,确保状态及时更新 * feat: 实现全局SnackBar支持并优化书签获取逻辑 添加全局ScaffoldMessengerKey以支持页面导航后显示SnackBar 重构书签获取逻辑使用getCachedBookmark替代直接访问bookmarks列表 更新测试用例以适配新的书签获取方式 * fix: 修复API响应状态码检查及异步标签加载问题 - 在ReadeckApiClient中增加201状态码作为有效响应 - 将标签加载方法改为异步执行 - 更新测试中的虚拟返回值以匹配实际类型 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4caf993 commit f683506

File tree

58 files changed

+9372
-172
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+9372
-172
lines changed

.trae/rules/project_rules.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,3 +615,7 @@ This project uses [semantic-release](https://github.com/semantic-release/semanti
615615
### Test-Driven Development (TDD)
616616

617617
This project follows a Test-Driven Development (TDD) approach. All new features or bug fixes should start with writing a failing unit test that describes the desired functionality or reproduces the bug. Only after the test is written should the implementation code be written to make the test pass.
618+
619+
### 注意事项
620+
621+
- 遇到 Mockito null safety 错误时,首先运行 flutter pub run build_runner build

android/app/build.gradle

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,19 @@ android {
3434
ndkVersion flutter.ndkVersion
3535

3636
compileOptions {
37-
sourceCompatibility JavaVersion.VERSION_1_8
38-
targetCompatibility JavaVersion.VERSION_1_8
37+
sourceCompatibility JavaVersion.VERSION_17
38+
targetCompatibility JavaVersion.VERSION_17
3939
}
4040

4141
kotlinOptions {
42-
jvmTarget = '1.8'
42+
jvmTarget = '17'
43+
}
44+
45+
// Use JVM Toolchain to ensure consistency
46+
java {
47+
toolchain {
48+
languageVersion = JavaLanguageVersion.of(17)
49+
}
4350
}
4451

4552
sourceSets {

android/app/src/main/AndroidManifest.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@
2424
<action android:name="android.intent.action.MAIN"/>
2525
<category android:name="android.intent.category.LAUNCHER"/>
2626
</intent-filter>
27+
28+
<!-- Intent filter for receiving shared text -->
29+
<intent-filter>
30+
<action android:name="android.intent.action.SEND" />
31+
<category android:name="android.intent.category.DEFAULT" />
32+
<data android:mimeType="text/plain" />
33+
</intent-filter>
34+
35+
<!-- Intent filter for receiving shared URLs -->
36+
<intent-filter>
37+
<action android:name="android.intent.action.SEND" />
38+
<category android:name="android.intent.category.DEFAULT" />
39+
<data android:mimeType="text/*" />
40+
</intent-filter>
2741
</activity>
2842
<!-- Don't delete the meta-data below.
2943
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

android/build.gradle

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@ allprojects {
33
google()
44
mavenCentral()
55
}
6+
7+
// Configure Java toolchain for all subprojects
8+
afterEvaluate { project ->
9+
if (project.hasProperty('android')) {
10+
android {
11+
compileOptions {
12+
sourceCompatibility JavaVersion.VERSION_17
13+
targetCompatibility JavaVersion.VERSION_17
14+
}
15+
}
16+
}
17+
if (project.hasProperty('kotlin')) {
18+
project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
19+
kotlinOptions {
20+
jvmTarget = '17'
21+
}
22+
}
23+
}
24+
}
625
}
726

827
subprojects {

lib/config/dependencies.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import 'package:readeck_app/data/repository/settings/settings_repository.dart';
99
import 'package:readeck_app/data/service/database_service.dart';
1010
import 'package:readeck_app/data/service/readeck_api_client.dart';
1111
import 'package:readeck_app/data/service/openrouter_api_client.dart';
12+
import 'package:readeck_app/data/service/web_content_service.dart';
13+
import 'package:readeck_app/data/repository/web_content/web_content_repository.dart';
14+
import 'package:readeck_app/data/repository/ai_tag_recommendation/ai_tag_recommendation_repository.dart';
1215
import 'package:readeck_app/domain/use_cases/bookmark_operation_use_cases.dart';
1316

1417
import 'package:readeck_app/data/repository/label/label_repository.dart';
@@ -22,6 +25,9 @@ List<SingleChildWidget> providers(String host, String token) {
2225
Provider(create: (context) => SharedPreferencesService()),
2326
Provider(create: (context) => ReadeckApiClient(host, token)),
2427
Provider(create: (context) => DatabaseService()),
28+
Provider(create: (context) => WebContentService()),
29+
Provider<WebContentRepository>(
30+
create: (context) => WebContentRepositoryImpl(context.read())),
2531
Provider(create: (context) {
2632
final prefsService = context.read<SharedPreferencesService>();
2733
final apiClient = context.read<ReadeckApiClient>();
@@ -34,6 +40,12 @@ List<SingleChildWidget> providers(String host, String token) {
3440
final openRouterClient = context.read<OpenRouterApiClient>();
3541
return OpenRouterRepository(openRouterClient);
3642
}),
43+
Provider(create: (context) {
44+
final openRouterClient = context.read<OpenRouterApiClient>();
45+
final settingsRepository = context.read<SettingsRepository>();
46+
return AiTagRecommendationRepository(
47+
openRouterClient, settingsRepository);
48+
}),
3749
ChangeNotifierProvider(
3850
create: (context) => MainAppViewModel(
3951
context.read<SettingsRepository>(),
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import 'dart:convert';
2+
import 'package:readeck_app/data/service/openrouter_api_client.dart';
3+
import 'package:readeck_app/data/service/web_content_service.dart';
4+
import 'package:readeck_app/data/repository/settings/settings_repository.dart';
5+
import 'package:readeck_app/main.dart';
6+
import 'package:result_dart/result_dart.dart';
7+
8+
/// AI标签推荐Repository
9+
/// 基于网页内容和现有标签,通过OpenRouter API提供智能标签推荐
10+
class AiTagRecommendationRepository {
11+
AiTagRecommendationRepository(
12+
this._openRouterApiClient,
13+
this._settingsRepository,
14+
);
15+
16+
final OpenRouterApiClient _openRouterApiClient;
17+
final SettingsRepository _settingsRepository;
18+
19+
/// 检查AI标签推荐是否可用
20+
bool get isAvailable {
21+
final apiKey = _settingsRepository.getOpenRouterApiKey();
22+
final selectedModel = _settingsRepository.getSelectedOpenRouterModel();
23+
return apiKey.isNotEmpty && selectedModel.isNotEmpty;
24+
}
25+
26+
/// 基于网页内容生成标签推荐
27+
///
28+
/// [webContent] - 网页内容
29+
/// [existingTags] - 现有的标签列表,用于参考
30+
/// [maxTags] - 最大推荐标签数量,默认5个
31+
///
32+
/// 返回推荐的标签列表
33+
AsyncResult<List<String>> generateTagRecommendations(
34+
WebContent webContent,
35+
List<String> existingTags, {
36+
int maxTags = 5,
37+
}) async {
38+
if (!isAvailable) {
39+
appLogger.w('AI标签推荐功能不可用:未配置API密钥或模型');
40+
return Failure(Exception('AI标签推荐功能不可用:请先配置OpenRouter API密钥和模型'));
41+
}
42+
43+
try {
44+
appLogger.i('开始生成AI标签推荐 - URL: ${webContent.url}');
45+
46+
final selectedModel = _settingsRepository.getSelectedOpenRouterModel();
47+
final targetLanguage = _settingsRepository.getAiTagTargetLanguage();
48+
49+
final systemPrompt = _buildSystemPrompt();
50+
final userPrompt =
51+
_buildUserPrompt(webContent, existingTags, maxTags, targetLanguage);
52+
53+
appLogger.d('使用模型: $selectedModel');
54+
appLogger.d('目标语言: $targetLanguage');
55+
appLogger.d('系统提示词长度: ${systemPrompt.length}');
56+
appLogger.d('用户提示词长度: ${userPrompt.length}');
57+
58+
// 最多重试3次
59+
for (int attempt = 1; attempt <= 3; attempt++) {
60+
appLogger.d('第 $attempt 次尝试生成标签');
61+
62+
// 调用OpenRouter API
63+
final result = await _openRouterApiClient.chatCompletion(
64+
model: selectedModel,
65+
messages: [
66+
{'role': 'system', 'content': systemPrompt},
67+
{'role': 'user', 'content': userPrompt},
68+
],
69+
temperature: 0.3, // 较低的温度确保结果更加一致和可预测
70+
maxTokens: 200, // 限制token数量
71+
);
72+
73+
if (result.isSuccess()) {
74+
final response = result.getOrThrow();
75+
final tags = _parseTagsFromResponse(response);
76+
77+
if (tags.isNotEmpty) {
78+
appLogger.i('AI标签推荐生成成功,共${tags.length}个标签: ${tags.join(", ")}');
79+
return Success(tags);
80+
} else {
81+
appLogger.w('第 $attempt 次尝试:标签解析失败,AI响应: $response');
82+
if (attempt == 3) {
83+
return Failure(Exception('AI标签推荐失败:经过3次尝试,无法解析有效的标签'));
84+
}
85+
}
86+
} else {
87+
final error = result.exceptionOrNull()!;
88+
appLogger.e('第 $attempt 次尝试:API调用失败', error: error);
89+
if (attempt == 3) {
90+
return Failure(error);
91+
}
92+
}
93+
}
94+
95+
return Failure(Exception('AI标签推荐失败:经过3次尝试仍然失败'));
96+
} catch (e) {
97+
appLogger.e('生成AI标签推荐时发生异常', error: e);
98+
return Failure(Exception('生成AI标签推荐时发生异常: $e'));
99+
}
100+
}
101+
102+
/// 构建系统提示词
103+
String _buildSystemPrompt() {
104+
// 预留系统提示词占位符,等待后续填充
105+
return '''
106+
Assign the most appropriate label(s) to the provided web page content. You will receive the following inputs: the web page content, a list of existing labels, and the expected label language. If suitable, prioritize using the existing labels. If none of the existing labels fit, generate a new label in the requested language that best describes the web page content.
107+
108+
- Carefully analyze the web page content to understand its main topics, themes, or purpose.
109+
- Compare your understanding with the existing labels and select the most relevant ones.
110+
- If no existing label is appropriate, generate a new, concise, and descriptive label in the specified language.
111+
- Do not include any explanations or reasoning in your output—only output the final label(s).
112+
113+
# Steps
114+
115+
1. Analyze the provided web page content to identify its main topic or purpose.
116+
2. Review the list of existing labels.
117+
3. Select the most relevant existing label(s) that match the content.
118+
4. If no existing label is suitable, create a new label in the expected language.
119+
5. Output the label(s) as a JSON array of strings.
120+
121+
# Output Format
122+
123+
Output only a JSON array of strings containing the assigned label(s). Do not include any explanations or additional text.
124+
125+
# Examples
126+
127+
**Example 1**
128+
129+
Input:
130+
- Web page content: "[Placeholder for a news article about electric vehicles]"
131+
- Existing labels: ["Technology", "Automotive", "Environment"]
132+
- Expected label language: "English"
133+
134+
Output:
135+
["Automotive"]
136+
137+
**Example 2**
138+
139+
Input:
140+
- Web page content: "[Placeholder for a tutorial on baking bread]"
141+
- Existing labels: ["Cooking", "Technology", "Travel"]
142+
- Expected label language: "English"
143+
144+
Output:
145+
["Cooking"]
146+
147+
**Example 3**
148+
149+
Input:
150+
- Web page content: "[Placeholder for a blog about meditation techniques]"
151+
- Existing labels: ["Fitness", "Wellness"]
152+
- Expected label language: "English"
153+
154+
Output:
155+
["Wellness"]
156+
157+
**Example 4**
158+
159+
Input:
160+
- Web page content: "[Placeholder for a guide to starting a business in China]"
161+
- Existing labels: ["Travel", "Technology"]
162+
- Expected label language: "English"
163+
164+
Output:
165+
["Business"]
166+
167+
# Notes
168+
169+
- Always use the expected label language for any new label.
170+
- If multiple existing labels are relevant, output all of them.
171+
- The output must be a pure JSON array, without code blocks or extra formatting.
172+
173+
174+
''';
175+
}
176+
177+
/// 构建用户提示词,包含目标语言、已存在的标签和网页内容
178+
String _buildUserPrompt(WebContent webContent, List<String> existingTags,
179+
int maxTags, String targetLanguage) {
180+
final buffer = StringBuffer();
181+
182+
// 目标语言
183+
buffer.writeln('# 目标语言');
184+
buffer.writeln(targetLanguage);
185+
buffer.writeln();
186+
187+
// 已存在的标签
188+
buffer.writeln('# 已存在的标签');
189+
if (existingTags.isNotEmpty) {
190+
buffer.writeln(existingTags.join(', '));
191+
} else {
192+
buffer.writeln('无');
193+
}
194+
buffer.writeln();
195+
196+
// 网页内容(全文输入,无需截断)
197+
buffer.writeln('# 网页内容');
198+
buffer.writeln('标题: ${webContent.title}');
199+
buffer.writeln('URL: ${webContent.url}');
200+
buffer.writeln();
201+
buffer.writeln('内容:');
202+
buffer.writeln(webContent.content);
203+
204+
return buffer.toString();
205+
}
206+
207+
/// 从AI响应中解析标签列表
208+
/// 只处理JSON数组格式,如: ["Wellness", "Technology"]
209+
List<String> _parseTagsFromResponse(String response) {
210+
try {
211+
// 清理响应文本
212+
String cleanResponse = response.trim();
213+
appLogger.d('原始AI响应: $cleanResponse');
214+
215+
// 查找JSON数组的开始和结束位置
216+
final startIndex = cleanResponse.indexOf('[');
217+
final endIndex = cleanResponse.lastIndexOf(']');
218+
219+
if (startIndex == -1 || endIndex == -1 || startIndex >= endIndex) {
220+
appLogger.w('响应中未找到有效的JSON数组格式');
221+
return [];
222+
}
223+
224+
// 提取JSON数组部分
225+
final jsonPart = cleanResponse.substring(startIndex, endIndex + 1);
226+
appLogger.d('提取的JSON部分: $jsonPart');
227+
228+
// 解析JSON数组
229+
final List<dynamic> jsonList = jsonDecode(jsonPart);
230+
final tags = jsonList
231+
.cast<String>()
232+
.where((tag) => tag.isNotEmpty && tag.length <= 15)
233+
.take(8)
234+
.toList();
235+
236+
appLogger.d('JSON格式解析标签结果: $tags');
237+
return tags;
238+
} catch (e) {
239+
appLogger.w('JSON解析失败: $e, 原始响应: $response');
240+
return [];
241+
}
242+
}
243+
}

lib/data/repository/bookmark/bookmark_repository.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,29 @@ class BookmarkRepository {
391391
return Failure(result.exceptionOrNull()!);
392392
}
393393

394+
/// 创建书签
395+
AsyncResult<void> createBookmark({
396+
required String url,
397+
String? title,
398+
List<String>? labels,
399+
}) async {
400+
appLogger.i('开始创建书签: $url');
401+
final result = await _readeckApiClient.createBookmark(
402+
url: url,
403+
title: title,
404+
labels: labels,
405+
);
406+
407+
if (result.isSuccess()) {
408+
appLogger.i('书签创建请求已提交,正在异步处理: $url');
409+
// 不需要立即添加到缓存,因为书签还在异步处理中
410+
return const Success(unit);
411+
} else {
412+
appLogger.e('书签创建失败: $url', error: result.exceptionOrNull());
413+
return Failure(result.exceptionOrNull()!);
414+
}
415+
}
416+
394417
/// 删除书签
395418
AsyncResult<void> deleteBookmark(String bookmarkId) async {
396419
appLogger.i('开始删除书签: $bookmarkId');

0 commit comments

Comments
 (0)