feat: 实现导航页基础功能
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
@@ -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 {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user