Files
Navius/frontend/lib/features/home/data/http_home_repository.dart
CNWei 039ea70edf feat: 实现导航页基础功能
- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
2026-09-19 23:17:36 +08:00

242 lines
7.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
submitUrl: siteMeta['submitUrl'] as String? ?? '',
);
}
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;
}