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,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;
}