feat: 进行功能分区方便后期维护
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import 'link_item.dart';
|
||||
import 'link_section.dart';
|
||||
import 'nav_node.dart';
|
||||
|
||||
/// 首页一次性需要的全部数据。
|
||||
///
|
||||
/// 打包成一个聚合对象:页面只调用一次仓库、只持有一份状态,
|
||||
/// 后续换成后端接口时也只需要在一个响应里组装出它。
|
||||
class HomeData {
|
||||
const HomeData({
|
||||
required this.siteName,
|
||||
required this.searchHint,
|
||||
required this.navNodes,
|
||||
required this.sections,
|
||||
});
|
||||
|
||||
/// 站点名,显示在侧栏顶部与浏览器标题。
|
||||
final String siteName;
|
||||
|
||||
/// 搜索框占位文案。
|
||||
final String searchHint;
|
||||
|
||||
/// 侧栏导航树。
|
||||
final List<NavNode> navNodes;
|
||||
|
||||
/// 内容区分组。
|
||||
///
|
||||
/// 数量可能少于导航叶子:原型里 `#icon`、`#stock`、`#dev`、`#api`
|
||||
/// 没有对应元素,点击时什么也不会发生。
|
||||
final List<LinkSection> sections;
|
||||
|
||||
/// 导航树里的全部叶子,按自上而下的顺序展开。
|
||||
List<NavNode> get navLeaves => navNodes
|
||||
.expand((NavNode node) => node.leaves)
|
||||
.toList(growable: false);
|
||||
|
||||
/// 全部站点(扁平化)。
|
||||
List<LinkItem> get allItems => <LinkItem>[
|
||||
for (final LinkSection section in sections) ...section.items,
|
||||
];
|
||||
|
||||
/// 站点总数。
|
||||
int get itemCount =>
|
||||
sections.fold(0, (int sum, LinkSection s) => sum + s.itemCount);
|
||||
|
||||
/// 原型把 `.active` 写死在第一个导航条目上,这里取同一个值的来源。
|
||||
String? get activeSectionId =>
|
||||
navLeaves.isEmpty ? null : navLeaves.first.id;
|
||||
|
||||
/// 按 id 取分区;原型里没有对应元素时返回 null。
|
||||
LinkSection? sectionById(String id) {
|
||||
for (final LinkSection section in sections) {
|
||||
if (section.id == id) {
|
||||
return section;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import '../../../../core/utils/text.dart';
|
||||
|
||||
/// 一张站点卡片,对应 `index.html` 里的 `.card`。
|
||||
class LinkItem {
|
||||
const LinkItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
this.description = '',
|
||||
});
|
||||
|
||||
/// 从 JSON 构造(将来接后端接口时使用)。
|
||||
factory LinkItem.fromJson(Map<String, dynamic> json) {
|
||||
return LinkItem(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
url: json['url'] as String,
|
||||
description: json['description'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 唯一标识。
|
||||
final String id;
|
||||
|
||||
/// 站点名称,同时用于生成卡片左上角的首字母图标。
|
||||
final String title;
|
||||
|
||||
/// 跳转地址。
|
||||
final String url;
|
||||
|
||||
/// 一句话描述。
|
||||
final String description;
|
||||
|
||||
/// 卡片图标显示的首字符。
|
||||
String get initial => firstChar(title).toUpperCase();
|
||||
|
||||
/// 展示用域名。
|
||||
String get host => Uri.tryParse(url)?.host ?? url;
|
||||
|
||||
/// 序列化为 JSON。
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'id': id,
|
||||
'title': title,
|
||||
'url': url,
|
||||
'description': description,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is LinkItem &&
|
||||
other.id == id &&
|
||||
other.title == title &&
|
||||
other.url == url &&
|
||||
other.description == description;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, title, url, description);
|
||||
|
||||
@override
|
||||
String toString() => 'LinkItem(id: $id, title: $title)';
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'link_item.dart';
|
||||
|
||||
/// 一个分组,对应 `index.html` 里带标题的一组卡片。
|
||||
class LinkSection {
|
||||
const LinkSection({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
/// 从 JSON 构造。
|
||||
///
|
||||
/// `items` 做了类型防御:字段缺失或类型不对时退化成空列表,
|
||||
/// 避免后端返回异常数据时整个页面直接崩掉。
|
||||
factory LinkSection.fromJson(Map<String, dynamic> json) {
|
||||
final Object? rawItems = json['items'];
|
||||
final List<LinkItem> items = rawItems is List
|
||||
? rawItems
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LinkItem.fromJson)
|
||||
.toList(growable: false)
|
||||
: const <LinkItem>[];
|
||||
|
||||
return LinkSection(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
|
||||
/// 唯一标识,同时用作锚点。
|
||||
final String id;
|
||||
|
||||
/// 分组标题。
|
||||
final String title;
|
||||
|
||||
/// 分组下的站点。
|
||||
final List<LinkItem> items;
|
||||
|
||||
/// 站点数量。
|
||||
int get itemCount => items.length;
|
||||
|
||||
/// 序列化为 JSON。
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'id': id,
|
||||
'title': title,
|
||||
'items': items.map((LinkItem item) => item.toJson()).toList(growable: false),
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
if (other is! LinkSection ||
|
||||
other.id != id ||
|
||||
other.title != title ||
|
||||
other.items.length != items.length) {
|
||||
return false;
|
||||
}
|
||||
for (int index = 0; index < items.length; index++) {
|
||||
if (items[index] != other.items[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(id, title, Object.hashAll(items));
|
||||
|
||||
@override
|
||||
String toString() => 'LinkSection(id: $id, title: $title, items: $itemCount)';
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import '../../../../core/utils/text.dart';
|
||||
|
||||
/// 侧栏导航节点。
|
||||
///
|
||||
/// `index3.html` 的侧栏是两层的:一级条目要么直接指向一个分区(如「常用推荐」),
|
||||
/// 要么是一个可以展开的分组(如「素材资源」),分组下面是若干二级条目。
|
||||
/// 这里用同一棵树表达两种形态——[children] 为空即为直接跳转的叶子。
|
||||
class NavNode {
|
||||
const NavNode({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.children = const <NavNode>[],
|
||||
});
|
||||
|
||||
/// 唯一标识;叶子节点同时也是内容区的锚点 id。
|
||||
final String id;
|
||||
|
||||
/// 显示文字。
|
||||
final String title;
|
||||
|
||||
/// 子节点;为空表示这是一个直接跳转的条目。
|
||||
final List<NavNode> children;
|
||||
|
||||
/// 是否是可展开的分组。
|
||||
bool get isGroup => children.isNotEmpty;
|
||||
|
||||
/// 该节点覆盖的所有叶子(自身是叶子时返回自身,否则递归收集后代)。
|
||||
List<NavNode> get leaves {
|
||||
if (children.isEmpty) {
|
||||
return <NavNode>[this];
|
||||
}
|
||||
return children
|
||||
.expand((NavNode child) => child.leaves)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// 侧栏折叠时显示的短标签。
|
||||
///
|
||||
/// 取标题首字,与 logo 折叠成「S」是同一套规则;
|
||||
/// 用 `runes` 保证中文与 emoji 不会被截成半个字符。
|
||||
String get shortLabel => firstChar(title);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is NavNode && other.id == id && other.title == title;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, title);
|
||||
|
||||
@override
|
||||
String toString() => 'NavNode(id: $id, title: $title, children: ${children.length})';
|
||||
}
|
||||
Reference in New Issue
Block a user