feat: 实现导航页基础功能

- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
This commit is contained in:
2026-09-19 21:53:15 +08:00
parent e2cc3ac30f
commit 1c98340fc5
42 changed files with 6054 additions and 140 deletions
@@ -0,0 +1,301 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/features/home/data/home_repository.dart';
import 'package:frontend/features/home/data/http_home_repository.dart';
import 'package:frontend/features/home/domain/models/home_data.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:http/http.dart' as http;
import 'package:http/testing.dart';
/// 和后端真实响应同构:两个一级分组,其中一个带子分组,还留一个空分组。
const String _navJson = '''
{
"groups": [
{
"id": "common",
"title": "常用推荐",
"source": "system",
"editable": false,
"sites": [
{ "id": "github", "title": "GitHub", "url": "https://github.com",
"description": "代码托管与开源社区", "categoryId": "common",
"source": "system", "order": 10, "editable": false,
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": "" },
{ "id": "zhihu", "title": "知乎", "url": "https://www.zhihu.com",
"description": "深度内容问答社区", "categoryId": "common",
"source": "system", "order": 20, "editable": false,
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": "" }
],
"children": []
},
{
"id": "materials",
"title": "素材资源",
"source": "system",
"editable": false,
"sites": [],
"children": [
{
"id": "design",
"title": "UI 设计",
"source": "system",
"editable": false,
"sites": [
{ "id": "figma", "title": "Figma", "url": "https://www.figma.com",
"description": "协同界面设计工具", "categoryId": "design",
"source": "system", "order": 10, "editable": false,
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": "" }
],
"children": []
},
{
"id": "icon",
"title": "图标库",
"source": "system",
"editable": false,
"sites": [],
"children": []
}
]
}
]
}
''';
HttpHomeRepository repositoryReturning(
String body, {
int status = 200,
void Function(http.Request request)? onRequest,
}) {
final MockClient client = MockClient((http.Request request) async {
onRequest?.call(request);
return http.Response.bytes(utf8.encode(body), status);
});
return HttpHomeRepository(baseUrl: 'http://api.test', client: client);
}
void main() {
group('映射后端响应', () {
late HomeData data;
setUp(() async {
data = await repositoryReturning(_navJson).fetchHomeData();
});
test('请求打到 /api/nav', () async {
Uri? requested;
await repositoryReturning(
_navJson,
onRequest: (http.Request request) => requested = request.url,
).fetchHomeData();
expect(requested.toString(), 'http://api.test/api/nav');
});
test('分组变成侧栏导航树', () {
expect(
data.navNodes.map((NavNode node) => node.title),
<String>['常用推荐', '素材资源'],
);
expect(data.navNodes[0].isGroup, isFalse, reason: '没有子分组就是直接跳转的条目');
expect(data.navNodes[1].isGroup, isTrue);
expect(
data.navNodes[1].children.map((NavNode node) => node.title),
<String>['UI 设计', '图标库'],
);
});
test('只有挂着网站的分组才进内容区', () {
expect(
data.sections.map((LinkSection section) => section.id),
<String>['common', 'design'],
);
// 空分组仍然留在侧栏里,只是点了没有对应内容。
expect(data.sectionById('icon'), isNull);
expect(
data.navLeaves.map((NavNode node) => node.id),
contains('icon'),
);
});
test('子分组的内容标题带上父级', () {
expect(data.sectionById('design')!.title, '素材资源 - UI 设计');
expect(data.sectionById('common')!.title, '常用推荐');
});
test('网站按后端给的顺序展开', () {
expect(
data.sectionById('common')!.items.map((item) => item.title),
<String>['GitHub', '知乎'],
);
expect(data.sectionById('design')!.items.single.url, 'https://www.figma.com');
expect(data.sectionById('design')!.items.single.description, '协同界面设计工具');
});
});
group('错误处理', () {
test('非 200 时带出后端的说明', () async {
final HttpHomeRepository repository = repositoryReturning(
'{"error":"找不到数据"}',
status: 500,
);
await expectLater(
repository.fetchHomeData(),
throwsA(
isA<HomeFailure>().having(
(HomeFailure failure) => failure.message,
'message',
contains('找不到数据'),
),
),
);
});
test('返回的不是 JSON 时给出明确提示', () async {
final HttpHomeRepository repository = repositoryReturning(
'<html>502 Bad Gateway</html>',
);
await expectLater(
repository.fetchHomeData(),
throwsA(
isA<HomeFailure>().having(
(HomeFailure failure) => failure.message,
'message',
contains('不是合法 JSON'),
),
),
);
});
test('缺少 groups 时给出明确提示', () async {
final HttpHomeRepository repository = repositoryReturning('{"ok":true}');
await expectLater(
repository.fetchHomeData(),
throwsA(
isA<HomeFailure>().having(
(HomeFailure failure) => failure.message,
'message',
contains('groups'),
),
),
);
});
test('连不上后端时给出地址', () async {
final MockClient client = MockClient(
(http.Request request) async => throw const SocketExceptionStub(),
);
final HttpHomeRepository repository = HttpHomeRepository(
baseUrl: 'http://127.0.0.1:8080',
client: client,
);
await expectLater(
repository.fetchHomeData(),
throwsA(
isA<HomeFailure>().having(
(HomeFailure failure) => failure.message,
'message',
contains('连不上后端'),
),
),
);
});
test('缺字段的条目被跳过,不会让整页失败', () async {
final HomeData data = await repositoryReturning('''
{"groups":[{"id":"g","title":"G","sites":[
{"id":"","title":"没有 id","url":"https://a.com"},
{"id":"ok","title":"正常","url":"https://b.com"}
],"children":[]}]}
''').fetchHomeData();
expect(data.sectionById('g')!.items, hasLength(1));
expect(data.sectionById('g')!.items.single.title, '正常');
});
});
group('提交网站', () {
const SubmitSiteDraft draft = SubmitSiteDraft(
title: 'Hugging Face',
url: 'https://huggingface.co',
description: '开源模型与数据集社区',
group: 'AI 工具',
subGroup: '模型社区',
submittedBy: '[email protected]',
);
test('POST 到 /api/sites,字段用 camelCase', () async {
http.Request? captured;
final HttpHomeRepository repository = HttpHomeRepository(
baseUrl: 'http://api.test',
client: MockClient((http.Request request) async {
captured = request;
return http.Response.bytes(utf8.encode('{}'), 201);
}),
);
await repository.submitSite(draft);
expect(captured!.method, 'POST');
expect(captured!.url.toString(), 'http://api.test/api/sites');
final Map<String, dynamic> body =
jsonDecode(captured!.body) as Map<String, dynamic>;
expect(body['title'], 'Hugging Face');
expect(body['url'], 'https://huggingface.co');
expect(body['group'], 'AI 工具');
expect(body['subGroup'], '模型社区');
expect(body['submittedBy'], '[email protected]');
expect(body.containsKey('categoryId'), isFalse, reason: '不能自己指定分组 id');
});
test('后端返回 400 时抛出后端的中文说明', () async {
final HttpHomeRepository repository = repositoryReturning(
'{"error":"url 不能是本机或内网地址:localhost"}',
status: 400,
);
await expectLater(
repository.submitSite(draft),
throwsA(
isA<HomeFailure>().having(
(HomeFailure failure) => failure.message,
'message',
allOf(contains('400'), contains('不能是本机或内网地址')),
),
),
);
});
test('连不上后端时给出地址', () async {
final HttpHomeRepository repository = HttpHomeRepository(
baseUrl: 'http://127.0.0.1:8080',
client: MockClient(
(http.Request request) async => throw const SocketExceptionStub(),
),
);
await expectLater(
repository.submitSite(draft),
throwsA(
isA<HomeFailure>().having(
(HomeFailure failure) => failure.message,
'message',
contains('连不上后端'),
),
),
);
});
});
}
/// 用来模拟「连不上」的异常。
class SocketExceptionStub implements Exception {
const SocketExceptionStub();
@override
String toString() => 'Connection refused';
}
@@ -7,7 +7,12 @@ import 'package:frontend/features/home/domain/models/nav_node.dart';
void main() {
const MockHomeRepository repository = MockHomeRepository();
final HomeData data = repository.fetchHomeData();
late HomeData data;
// 取数改成异步之后,每个用例开始前先拿一次。
setUp(() async {
data = await repository.fetchHomeData();
});
group('导航树(对照 index3.html 的 nav-group)', () {
test('三个一级条目:一个直接跳转,两个可展开分组', () {