feat: 实现导航页基础功能

- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
This commit is contained in:
2026-09-19 23:17:36 +08:00
parent 1c98340fc5
commit 039ea70edf
26 changed files with 796 additions and 2583 deletions
@@ -8,15 +8,11 @@ import 'package:frontend/features/home/data/home_repository.dart';
import 'package:frontend/features/home/data/mock_home_data.dart';
import 'package:frontend/features/home/data/mock_home_repository.dart';
import 'package:frontend/features/home/domain/models/home_data.dart';
import 'package:frontend/features/home/domain/models/link_item.dart';
import 'package:frontend/features/home/domain/models/link_section.dart';
import 'package:frontend/features/home/domain/models/nav_node.dart';
import 'package:frontend/features/home/domain/models/submit_site_draft.dart';
import 'package:frontend/features/home/presentation/pages/home_page.dart';
import 'package:frontend/features/home/presentation/widgets/link_card.dart';
import 'package:frontend/features/home/presentation/widgets/link_section_view.dart';
import 'package:frontend/features/home/presentation/widgets/sidebar_nav.dart';
import 'package:frontend/features/home/presentation/widgets/site_form_dialog.dart';
import '../../../helpers/fake_url_opener.dart';
@@ -382,197 +378,18 @@ void main() {
});
group('提交网站', () {
testWidgets('点侧栏底部按钮打开表单;提交成功后刷新一次并提示', (WidgetTester tester) async {
final _RecordingRepository repository = _RecordingRepository();
// 表单比默认视口高,用大一点的窗口,免得字段在屏幕外点不到。
await pumpPage(
tester,
size: const Size(1000, 1200),
repository: repository,
);
expect(repository.fetches, 1);
await tester.tap(navText('提交网站'));
await tester.pumpAndSettle();
expect(find.text('网站名称'), findsOneWidget);
expect(find.text('提交的网站会放在你自己的分组里,不会进内置分类。'), findsOneWidget);
// 页面里除了表单还有顶部搜索框,这里限定在对话框内部找输入框。
Finder field(int index) => find
.descendant(
of: find.byType(SiteFormDialog),
matching: find.byType(TextField),
)
.at(index);
await tester.enterText(field(0), 'Hugging Face');
await tester.enterText(field(1), 'https://huggingface.co');
await tester.enterText(field(3), 'AI 工具');
await tester.pump();
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(repository.submitted, hasLength(1));
expect(repository.submitted.single.group, 'AI 工具');
expect(repository.fetches, 2, reason: '提交成功后要重新拉一次数据');
expect(find.text('已提交,感谢'), findsOneWidget);
expect(find.text('网站名称'), findsNothing, reason: '对话框已经关掉');
});
});
group('编辑与删除', () {
testWidgets('可修改的条目能编辑,保存后刷新', (WidgetTester tester) async {
final _MutableRepository repository = _MutableRepository();
await pumpPage(
tester,
size: const Size(1000, 1200),
repository: repository,
);
expect(repository.fetches, 1);
await tester.tap(find.byTooltip('编辑'));
await tester.pumpAndSettle();
// 表单预填了现有内容,提交者字段不出现。
expect(find.text('编辑网站'), findsOneWidget);
expect(
tester
.widget<TextField>(
find
.descendant(
of: find.byType(SiteFormDialog),
matching: find.byType(TextField),
)
.at(0),
)
.controller!
.text,
'Hugging Face',
);
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(repository.updated, <String>['s-1']);
expect(repository.fetches, 2, reason: '保存成功后要重新拉一次数据');
expect(find.text('已保存'), findsOneWidget);
});
testWidgets('删除会先确认,确认后才真的删并刷新', (WidgetTester tester) async {
final _MutableRepository repository = _MutableRepository();
await pumpPage(
tester,
size: const Size(1000, 1200),
repository: repository,
);
await tester.tap(find.byTooltip('删除'));
await tester.pumpAndSettle();
expect(find.textContaining('确定要删除「Hugging Face」'), findsOneWidget);
// 先点取消:什么都不该发生。
await tester.tap(find.text('取消'));
await tester.pumpAndSettle();
expect(repository.deleted, isEmpty);
await tester.tap(find.byTooltip('删除'));
await tester.pumpAndSettle();
await tester.tap(find.text('删除'));
await tester.pumpAndSettle();
expect(repository.deleted, <String>['s-1']);
expect(repository.fetches, 2);
expect(find.text('已删除'), findsOneWidget);
});
testWidgets('内置条目没有编辑和删除入口', (WidgetTester tester) async {
testWidgets('点侧栏底部按钮跳到后端配的提交入口', (WidgetTester tester) async {
await pumpPage(tester);
expect(find.byType(LinkCard), findsWidgets);
expect(find.byTooltip('编辑'), findsNothing);
expect(find.byTooltip('删除'), findsNothing);
await tester.tap(navText('提交网站'));
await tester.pump();
// 地址来自后端 `site.submitUrl`,页面不再自己弹表单。
expect(urlOpener.openedUrls, <String>['https://mattermost.example.com']);
});
});
}
/// 返回一条「用户提交」的站点(editable),并记录改删调用。
class _MutableRepository implements HomeRepository {
int fetches = 0;
final List<String> updated = <String>[];
final List<String> deleted = <String>[];
static const HomeData _data = HomeData(
siteName: 'Suxing Nav',
searchHint: '输入关键字搜索全网资源...',
navNodes: <NavNode>[NavNode(id: 'g-1', title: 'AI 工具', editable: true)],
sections: <LinkSection>[
LinkSection(
id: 'g-1',
title: 'AI 工具',
items: <LinkItem>[
LinkItem(
id: 's-1',
title: 'Hugging Face',
url: 'https://huggingface.co',
description: '开源模型与数据集社区',
categoryId: 'g-1',
editable: true,
),
],
),
],
);
@override
Future<HomeData> fetchHomeData() async {
fetches++;
return _data;
}
@override
Future<void> submitSite(SubmitSiteDraft draft) async {}
@override
Future<void> updateSite(String id, SubmitSiteDraft draft) async {
updated.add(id);
}
@override
Future<void> deleteSite(String id) async {
deleted.add(id);
}
}
/// 记录取数次数与提交内容,用来验证页面的联调行为。
class _RecordingRepository implements HomeRepository {
int fetches = 0;
final List<SubmitSiteDraft> submitted = <SubmitSiteDraft>[];
final List<String> updated = <String>[];
final List<String> deleted = <String>[];
@override
Future<HomeData> fetchHomeData() async {
fetches++;
return mockHomeData;
}
@override
Future<void> submitSite(SubmitSiteDraft draft) async {
submitted.add(draft);
}
@override
Future<void> updateSite(String id, SubmitSiteDraft draft) async {
updated.add(id);
}
@override
Future<void> deleteSite(String id) async {
deleted.add(id);
}
}
/// 数据一直不回来,用来测加载中的那一帧。
class _PendingRepository implements HomeRepository {
final Completer<HomeData> completer = Completer<HomeData>();
@@ -1,235 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/features/home/data/home_repository.dart';
import 'package:frontend/features/home/data/mock_home_data.dart';
import 'package:frontend/features/home/domain/models/home_data.dart';
import 'package:frontend/features/home/domain/models/nav_node.dart';
import 'package:frontend/features/home/domain/models/submit_site_draft.dart';
import 'package:frontend/features/home/presentation/widgets/site_form_dialog.dart';
/// 记录提交内容的假仓库。[failWith] 有值时模拟后端报错,[gate] 用来卡住请求。
class _RecordingRepository implements HomeRepository {
final List<SubmitSiteDraft> submitted = <SubmitSiteDraft>[];
String? failWith;
Completer<void>? gate;
@override
Future<HomeData> fetchHomeData() async => mockHomeData;
@override
Future<void> submitSite(SubmitSiteDraft draft) async {
if (gate != null) {
await gate!.future;
}
final String? failure = failWith;
if (failure != null) {
throw HomeFailure(failure);
}
submitted.add(draft);
}
@override
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
@override
Future<void> deleteSite(String id) async {}
}
/// 侧栏那棵树:内置分组不可选,用户分组可选。
const List<NavNode> _navNodes = <NavNode>[
NavNode(id: 'common', title: '常用推荐'),
NavNode(
id: 'g-1',
title: 'AI 工具',
editable: true,
children: <NavNode>[
NavNode(id: 'g-2', title: '模型社区', editable: true),
],
),
];
void main() {
late _RecordingRepository repository;
late bool? dialogResult;
setUp(() {
repository = _RecordingRepository();
dialogResult = null;
});
Future<void> openDialog(
WidgetTester tester, {
String heading = '提交网站',
SubmitSiteDraft? initial,
}) async {
// 表单比默认测试视口高,放大一点免得字段跑到屏幕外。
tester.view.physicalSize = const Size(1000, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) => TextButton(
onPressed: () async {
dialogResult = await showSiteFormDialog(
context: context,
navNodes: _navNodes,
heading: heading,
initial: initial,
onSubmit: (SubmitSiteDraft draft) =>
repository.submitSite(draft),
);
},
child: const Text('打开'),
),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
}
/// 按顺序填字段:0 名称、1 地址、2 描述、3 分组、4 子分组、5 称呼。
Future<void> fill(WidgetTester tester, int index, String value) async {
await tester.enterText(find.byType(TextField).at(index), value);
await tester.pump();
}
Future<void> fillValidForm(WidgetTester tester) async {
await fill(tester, 0, 'Hugging Face');
await fill(tester, 1, 'https://huggingface.co');
await fill(tester, 2, '开源模型与数据集社区');
await fill(tester, 3, 'AI 工具');
await fill(tester, 4, '模型社区');
await fill(tester, 5, '[email protected]');
}
testWidgets('空表单提交会逐项提示,且不发请求', (WidgetTester tester) async {
await openDialog(tester);
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(find.text('请填写网站名称'), findsOneWidget);
expect(find.text('请填写网站地址'), findsOneWidget);
expect(find.text('请填写分组名称'), findsOneWidget);
expect(repository.submitted, isEmpty);
expect(dialogResult, isNull, reason: '校验没过时对话框不关');
});
testWidgets('内网地址会被拦下来', (WidgetTester tester) async {
await openDialog(tester);
await fill(tester, 0, '内网');
await fill(tester, 1, 'http://192.168.1.10');
await fill(tester, 3, 'AI 工具');
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(find.text('不能填本机或内网地址'), findsOneWidget);
expect(repository.submitted, isEmpty);
});
testWidgets('合法内容提交给仓库,成功后关闭并返回 true', (WidgetTester tester) async {
await openDialog(tester);
await fillValidForm(tester);
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(repository.submitted, hasLength(1));
final SubmitSiteDraft draft = repository.submitted.single;
expect(draft.title, 'Hugging Face');
expect(draft.url, 'https://huggingface.co');
expect(draft.group, 'AI 工具');
expect(draft.subGroup, '模型社区');
expect(draft.submittedBy, '[email protected]');
expect(dialogResult, isTrue);
expect(find.text('取消'), findsNothing, reason: '对话框已经关掉');
});
testWidgets('后端报错时留在表单里显示原因', (WidgetTester tester) async {
repository.failWith = 'url 不能是本机或内网地址:localhost';
await openDialog(tester);
await fillValidForm(tester);
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(find.textContaining('不能是本机或内网地址'), findsOneWidget);
expect(repository.submitted, isEmpty);
expect(dialogResult, isNull);
expect(find.text('取消'), findsOneWidget, reason: '还能改了再提交');
});
testWidgets('提交中按钮变成「提交中…」且不可再点', (WidgetTester tester) async {
repository.gate = Completer<void>();
await openDialog(tester);
await fillValidForm(tester);
await tester.tap(find.text('提交'));
await tester.pump();
expect(find.text('提交中…'), findsOneWidget);
repository.gate!.complete();
await tester.pumpAndSettle();
expect(dialogResult, isTrue);
});
testWidgets('只给用户分组建议,点一下填进输入框', (WidgetTester tester) async {
await openDialog(tester);
// 内置分组不出现在建议里。
expect(find.text('AI 工具'), findsOneWidget, reason: '用户分组建议');
expect(find.text('常用推荐'), findsNothing, reason: '内置分组不可选');
await tester.tap(find.text('AI 工具'));
await tester.pumpAndSettle();
final TextField groupField = tester.widget<TextField>(
find.byType(TextField).at(3),
);
expect(groupField.controller!.text, 'AI 工具');
// 分组定了之后,它下面已有的子分组才出现。
expect(find.text('模型社区'), findsOneWidget);
});
testWidgets('编辑模式预填现有内容,且不显示提交者字段', (WidgetTester tester) async {
await openDialog(
tester,
heading: '编辑网站',
initial: const SubmitSiteDraft(
title: 'Hugging Face',
url: 'https://huggingface.co',
description: '开源模型与数据集社区',
group: 'AI 工具',
subGroup: '模型社区',
),
);
expect(find.text('编辑网站'), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField).at(0)).controller!.text,
'Hugging Face',
);
expect(
tester.widget<TextField>(find.byType(TextField).at(1)).controller!.text,
'https://huggingface.co',
);
expect(
tester.widget<TextField>(find.byType(TextField).at(3)).controller!.text,
'AI 工具',
);
// 后端 PATCH 不接受 submittedBy,编辑时就不显示这个字段。
expect(find.text('你的称呼或邮箱'), findsNothing);
// 分组已填好,它下面的子分组建议直接出来。
// (子分组输入框里也预填了同样的文字,所以这里用 findsWidgets。)
expect(find.text('模型社区'), findsWidgets);
});
}