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 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) { 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 ? rawSite : const {}, ); } @override Future submitSite(SubmitSiteDraft draft) async { final Uri uri = Uri.parse('$_baseUrl/api/sites'); final http.Response response; try { response = await _client .post( uri, headers: const { '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 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 { '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 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 groups, Map siteMeta) { final List navNodes = []; final List sections = []; for (final Object? group in groups) { if (group is Map) { 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 json, { required String parentTitle, required List 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 sites = _sites(json); if (sites.isNotEmpty && id.isNotEmpty) { sections.add(LinkSection(id: id, title: sectionTitle, items: sites)); } final List children = []; final Object? rawChildren = json['children']; if (rawChildren is List) { for (final Object? child in rawChildren) { if (child is Map) { children.add( _navNode(child, parentTitle: title, sections: sections), ); } } } return NavNode( id: id, title: title, children: children, // 后端给了 editable;缺省按内置处理(内置不可选)。 editable: json['editable'] as bool? ?? false, ); } List _sites(Map json) { final Object? raw = json['sites']; if (raw is! List) { return const []; } final List items = []; for (final Object? site in raw) { if (site is! Map) { 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 && decoded['error'] is String) { return decoded['error'] as String; } } catch (_) { // 不是 JSON 就退回原文。 } return body.isEmpty ? '没有返回内容' : body; }