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
@@ -1,13 +1,36 @@
import '../domain/models/home_data.dart';
import '../domain/models/submit_site_draft.dart';
/// 首页数据仓库契约。
///
/// 界面层只认这个接口,不关心数据来自本地 mock 还是 Rust 后端。
///
/// 目前是同步返回:mock 数据是恒定常量,加一层 Future 只会凭空多出
/// loading / error 分支。等真正走网络请求时,把返回值改成
/// `Future<HomeData>`,改动范围仅限实现类与页面的一次数据加载。
/// 两个方法都是异步的:接上后端之后「加载中 / 提交中」和「失败」都是必须
/// 表达的状态,所以契约从一开始就按异步来——mock 实现直接返回已经就绪的值。
abstract interface class HomeRepository {
/// 读取首页需要的全部数据。
HomeData fetchHomeData();
Future<HomeData> fetchHomeData();
/// 提交一个网站。
///
/// 成功不返回内容:后端会回创建出来的条目,但页面用不到(提交完重新拉一次
/// `/api/nav` 就有了)。失败时抛 [HomeFailure],`message` 可以直接展示。
Future<void> submitSite(SubmitSiteDraft draft);
/// 修改一个已有的网站(只能改用户提交 / 接口推送进来的,系统内置的会返回 403)。
Future<void> updateSite(String id, SubmitSiteDraft draft);
/// 删除一个已有的网站(同样只对可修改的条目有效)。
Future<void> deleteSite(String id);
}
/// 取数或提交失败。[message] 是可以直接显示给用户的说明。
class HomeFailure implements Exception {
const HomeFailure(this.message);
/// 面向用户的说明。
final String message;
@override
String toString() => message;
}
@@ -0,0 +1,240 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../domain/models/home_data.dart';
import '../domain/models/link_item.dart';
import '../domain/models/link_section.dart';
import '../domain/models/nav_node.dart';
import '../domain/models/submit_site_draft.dart';
import 'home_repository.dart';
/// 后端地址。
///
/// 默认指向本机后端,打包时用 `--dart-define=NAVIUS_API_BASE=https://…` 覆盖:
/// `flutter build web --dart-define=NAVIUS_API_BASE=https://api.example.com`
const String _apiBase = String.fromEnvironment(
'NAVIUS_API_BASE',
defaultValue: 'http://127.0.0.1:8080',
);
/// 接口没给站点信息时的兜底值。正常情况下这两个值来自 `GET /api/nav` 的
/// `site` 字段(后端 `data/site.json`)。
const String _fallbackSiteName = 'Suxing Nav';
const String _fallbackSearchHint = '输入关键字搜索全网资源...';
/// 从后端读取首页数据。
class HttpHomeRepository implements HomeRepository {
HttpHomeRepository({String? baseUrl, http.Client? client})
: _baseUrl = baseUrl ?? _apiBase,
_client = client ?? http.Client();
final String _baseUrl;
final http.Client _client;
@override
Future<HomeData> fetchHomeData() async {
final Uri uri = Uri.parse('$_baseUrl/api/nav');
final http.Response response;
try {
response = await _client.get(uri).timeout(const Duration(seconds: 10));
} catch (error) {
throw HomeFailure('连不上后端($_baseUrl):$error');
}
if (response.statusCode != 200) {
throw HomeFailure(
'后端返回 ${response.statusCode}:${_errorMessage(response)}',
);
}
final Object? decoded;
try {
decoded = jsonDecode(utf8.decode(response.bodyBytes));
} catch (error) {
throw const HomeFailure('后端返回的不是合法 JSON');
}
if (decoded is! Map<String, dynamic>) {
throw const HomeFailure('后端返回的结构不对:顶层不是对象');
}
final Object? groups = decoded['groups'];
if (groups is! List) {
throw const HomeFailure('后端返回的结构不对:缺少 groups 数组');
}
final Object? rawSite = decoded['site'];
return _toHomeData(
groups,
rawSite is Map<String, dynamic> ? rawSite : const <String, dynamic>{},
);
}
@override
Future<void> submitSite(SubmitSiteDraft draft) async {
final Uri uri = Uri.parse('$_baseUrl/api/sites');
final http.Response response;
try {
response = await _client
.post(
uri,
headers: const <String, String>{
'content-type': 'application/json; charset=utf-8',
},
body: jsonEncode(draft.toJson()),
)
.timeout(const Duration(seconds: 10));
} catch (error) {
throw HomeFailure('连不上后端($_baseUrl):$error');
}
if (response.statusCode != 201) {
throw HomeFailure(
'提交失败(${response.statusCode}):${_errorMessage(response)}',
);
}
}
@override
Future<void> updateSite(String id, SubmitSiteDraft draft) async {
final Uri uri = Uri.parse('$_baseUrl/api/sites/${Uri.encodeComponent(id)}');
final http.Response response;
try {
response = await _client
.patch(
uri,
headers: const <String, String>{
'content-type': 'application/json; charset=utf-8',
},
body: jsonEncode(draft.toJson()),
)
.timeout(const Duration(seconds: 10));
} catch (error) {
throw HomeFailure('连不上后端($_baseUrl):$error');
}
if (response.statusCode != 200) {
throw HomeFailure(
'保存失败(${response.statusCode}):${_errorMessage(response)}',
);
}
}
@override
Future<void> deleteSite(String id) async {
final Uri uri = Uri.parse('$_baseUrl/api/sites/${Uri.encodeComponent(id)}');
final http.Response response;
try {
response = await _client.delete(uri).timeout(const Duration(seconds: 10));
} catch (error) {
throw HomeFailure('连不上后端($_baseUrl):$error');
}
if (response.statusCode != 204) {
throw HomeFailure(
'删除失败(${response.statusCode}):${_errorMessage(response)}',
);
}
}
}
/// 把 `/api/nav` 的响应映射成前端模型。
///
/// 分组直接变成侧栏的导航树;**只有挂着网站的组才会进入内容区**,
/// 空分组只在侧栏里当导航项——这与原型里那几个没有内容的锚点一致。
HomeData _toHomeData(List<Object?> groups, Map<String, dynamic> siteMeta) {
final List<NavNode> navNodes = <NavNode>[];
final List<LinkSection> sections = <LinkSection>[];
for (final Object? group in groups) {
if (group is Map<String, dynamic>) {
navNodes.add(_navNode(group, parentTitle: '', sections: sections));
}
}
return HomeData(
siteName: siteMeta['name'] as String? ?? _fallbackSiteName,
searchHint: siteMeta['searchHint'] as String? ?? _fallbackSearchHint,
navNodes: navNodes,
sections: sections,
);
}
NavNode _navNode(
Map<String, dynamic> json, {
required String parentTitle,
required List<LinkSection> sections,
}) {
final String id = json['id'] as String? ?? '';
final String title = json['title'] as String? ?? '';
// 子分组在内容区的标题带上父级(「素材资源 - UI 设计」),
// 侧栏里仍然只显示「UI 设计」——和原型一致。
final String sectionTitle = parentTitle.isEmpty
? title
: '$parentTitle - $title';
final List<LinkItem> sites = _sites(json);
if (sites.isNotEmpty && id.isNotEmpty) {
sections.add(LinkSection(id: id, title: sectionTitle, items: sites));
}
final List<NavNode> children = <NavNode>[];
final Object? rawChildren = json['children'];
if (rawChildren is List) {
for (final Object? child in rawChildren) {
if (child is Map<String, dynamic>) {
children.add(
_navNode(child, parentTitle: title, sections: sections),
);
}
}
}
return NavNode(
id: id,
title: title,
children: children,
// 后端给了 editable;缺省按内置处理(内置不可选)。
editable: json['editable'] as bool? ?? false,
);
}
List<LinkItem> _sites(Map<String, dynamic> json) {
final Object? raw = json['sites'];
if (raw is! List) {
return const <LinkItem>[];
}
final List<LinkItem> items = <LinkItem>[];
for (final Object? site in raw) {
if (site is! Map<String, dynamic>) {
continue;
}
final String id = site['id'] as String? ?? '';
final String title = site['title'] as String? ?? '';
final String url = site['url'] as String? ?? '';
if (id.isEmpty || title.isEmpty || url.isEmpty) {
// 缺字段的条目直接跳过:后端已经校验过,这里只是不想让一条坏数据
// 把整个页面打挂。
continue;
}
items.add(
LinkItem(
id: id,
title: title,
url: url,
categoryId: site['categoryId'] as String? ?? '',
description: site['description'] as String? ?? '',
editable: site['editable'] as bool? ?? false,
),
);
}
return items;
}
/// 后端出错时统一返回 `{"error":"…"}`,能取到就显示那句话。
///
/// 用 `bodyBytes` 自己按 UTF-8 解,而不是 `response.body`:后者在响应没带
/// charset 时会退回 latin1,中文会变成乱码。
String _errorMessage(http.Response response) {
final String body = utf8.decode(response.bodyBytes, allowMalformed: true);
try {
final Object? decoded = jsonDecode(body);
if (decoded is Map<String, dynamic> && decoded['error'] is String) {
return decoded['error'] as String;
}
} catch (_) {
// 不是 JSON 就退回原文。
}
return body.isEmpty ? '没有返回内容' : body;
}
@@ -1,4 +1,5 @@
import '../domain/models/home_data.dart';
import '../domain/models/submit_site_draft.dart';
import 'home_repository.dart';
import 'mock_home_data.dart';
@@ -10,5 +11,16 @@ class MockHomeRepository implements HomeRepository {
const MockHomeRepository();
@override
HomeData fetchHomeData() => mockHomeData;
Future<HomeData> fetchHomeData() async => mockHomeData;
@override
Future<void> submitSite(SubmitSiteDraft draft) async {
// mock 实现什么都不做:需要断言提交内容的用例会注入自己的假实现。
}
@override
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
@override
Future<void> deleteSite(String id) async {}
}