feat: 实现导航页基础功能
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
+39
-6
@@ -1,11 +1,37 @@
|
||||
# Navius 前端(Flutter Web)
|
||||
|
||||
导航站前端,使用 Flutter 构建。当前目标平台只有 Web,数据全部来自本地 mock,
|
||||
不发起网络请求;后端(Rust)尚未接入。
|
||||
导航站前端,使用 Flutter 构建。当前目标平台只有 Web,数据来自 Rust 后端
|
||||
(`GET /api/nav`)。
|
||||
|
||||
界面基准是仓库根目录的 `index3.html`:可折叠的层级侧栏 + 居中的顶部控制栏
|
||||
(折叠按钮 + 搜索框)+ 分组卡片网格 + 右下角圆形回到顶部。
|
||||
|
||||
## 和后端一起跑
|
||||
|
||||
前端启动时会请求后端,所以两个都要起:
|
||||
|
||||
```bash
|
||||
cd backend && cargo run # 默认 http://127.0.0.1:8080
|
||||
cd frontend && flutter run -d chrome
|
||||
```
|
||||
|
||||
后端地址默认是 `http://127.0.0.1:8080`,打包或换环境时用编译期变量覆盖:
|
||||
|
||||
```bash
|
||||
flutter build web --dart-define=NAVIUS_API_BASE=https://api.example.com
|
||||
```
|
||||
|
||||
后端没起来时页面会显示「加载失败」和具体原因,并给一个「重试」按钮。
|
||||
|
||||
侧栏底部的「提交网站」会打开提交表单:填完直接进后端的用户分组(不需要审核),
|
||||
提交成功后页面会静默重新拉一次数据,新站点立刻出现在侧栏里。
|
||||
|
||||
用户提交 / 接口推送进来的站点(后端返回的 `editable == true`)在卡片悬停时右上角
|
||||
会露出「编辑 / 删除」:编辑复用同一个表单(预填现有内容)走 `PATCH`,
|
||||
删除会先确认再走 `DELETE`,两个操作成功后都会静默刷新。系统内置的卡片没有这两个入口。
|
||||
|
||||
站点名和搜索框提示也来自后端(`data/site.json`),前端不再写死。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
@@ -27,12 +53,19 @@ flutter build web # 构建产物输出到 build/web
|
||||
|
||||
## 接后端的位置
|
||||
|
||||
页面只依赖仓库接口,不关心数据来自 mock 还是 Rust 后端:
|
||||
页面只依赖仓库接口,不关心数据来自后端还是本地 mock:
|
||||
|
||||
- 首页:`lib/features/home/data/home_repository.dart`
|
||||
- 契约:`lib/features/home/data/home_repository.dart`
|
||||
- 后端实现:`lib/features/home/data/http_home_repository.dart`
|
||||
- mock 实现:`lib/features/home/data/mock_home_repository.dart`(测试用)
|
||||
|
||||
接入时新增一个实现类,并把 `fetchHomeData` 的返回类型改成 `Future<HomeData>` 即可,
|
||||
`domain/` 与 `presentation/` 基本不需要改动。
|
||||
接口对应关系:读 `GET /api/nav`、提交 `POST /api/sites`、编辑 `PATCH /api/sites/{id}`、
|
||||
删除 `DELETE /api/sites/{id}`。表单在 `presentation/widgets/site_form_dialog.dart`
|
||||
(新增和编辑共用),设计见 [docs/submit-site-form.md](../docs/submit-site-form.md)。
|
||||
|
||||
接口返回的分组树在 `http_home_repository.dart` 里映射成前端模型:分组变成侧栏导航,
|
||||
**只有挂着网站的分组才进内容区**(空分组留在侧栏,点了没有对应内容——和原型一致),
|
||||
子分组的内容标题会带上父级(「素材资源 - UI 设计」)。
|
||||
|
||||
完整的目录树、设计令牌映射、响应式规则与实现取舍见
|
||||
[docs/frontend-architecture.md](../docs/frontend-architecture.md)。
|
||||
|
||||
@@ -2,27 +2,27 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'core/services/web_url_opener.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'features/home/data/mock_home_repository.dart';
|
||||
import 'features/home/data/http_home_repository.dart';
|
||||
import 'features/home/presentation/pages/home_page.dart';
|
||||
|
||||
/// 应用根组件。
|
||||
///
|
||||
/// 只负责三件事:配置全局主题、装配依赖、指定首页。
|
||||
/// 这里是「具体实现」接起来的地方,所以只有它需要知道
|
||||
/// [MockHomeRepository] 与 [WebUrlOpener] 的存在。
|
||||
/// 这里是「具体实现」接起来的地方,所以只有它需要知道首页数据来自后端:
|
||||
/// [HttpHomeRepository] 默认打 `http://127.0.0.1:8080`,
|
||||
/// 打包时用 `--dart-define=NAVIUS_API_BASE=…` 指向真实地址。
|
||||
class NaviusApp extends StatelessWidget {
|
||||
const NaviusApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
// 与 HomeData.siteName 保持一致;接入后端后这一层由接口返回的数据驱动。
|
||||
title: 'Suxing Nav',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light,
|
||||
home: const HomePage(
|
||||
repository: MockHomeRepository(),
|
||||
urlOpener: WebUrlOpener(),
|
||||
home: HomePage(
|
||||
repository: HttpHomeRepository(),
|
||||
urlOpener: const WebUrlOpener(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,6 +155,33 @@ abstract final class AppSizes {
|
||||
/// 悬停时上移距离(`translateY(-3px)`)。
|
||||
static const double backToTopHoverLift = 3;
|
||||
|
||||
// ---- 通用间距 ----
|
||||
//
|
||||
// 上面的常量按「用在哪」命名;表单这类地方间距是通用的,
|
||||
// 这里给一套通用刻度,避免在组件里写裸数字。
|
||||
static const double spaceXs = 4;
|
||||
static const double spaceSm = 8;
|
||||
static const double spaceMd = 12;
|
||||
static const double spaceLg = 16;
|
||||
static const double spaceXl = 24;
|
||||
|
||||
// ---- 表单 ----
|
||||
|
||||
/// 对话框内边距。
|
||||
static const double dialogPadding = 24;
|
||||
|
||||
/// 对话框最大宽度。
|
||||
static const double dialogMaxWidth = 480;
|
||||
|
||||
/// 字段标签与输入框之间的间距。
|
||||
static const double fieldLabelGap = 6;
|
||||
|
||||
/// 多行输入框高度(3 行)。
|
||||
static const double fieldMultilineHeight = 88;
|
||||
|
||||
/// 对话框右上角关闭按钮的点击热区。
|
||||
static const double iconButtonSize = 28;
|
||||
|
||||
// ---- 通用 ----
|
||||
|
||||
/// 通用过渡时长(CSS 里的 `transition: 0.2s`)。
|
||||
|
||||
@@ -44,6 +44,16 @@ abstract final class AppColors {
|
||||
/// 搜索框聚焦时的外发光(`0 0 0 3px rgba(59,130,246,.15)`)。
|
||||
static const Color focusRing = Color(0x263B82F6);
|
||||
|
||||
/// 错误色。
|
||||
///
|
||||
/// `index3.html` 的调色板里没有红色,但表单的字段错误、失败提示需要一个
|
||||
/// 明确区别于正文的颜色,所以新增这一个语义令牌——只用在错误提示上,
|
||||
/// 不参与任何现有视觉。
|
||||
static const Color danger = Color(0xFFE5484D);
|
||||
|
||||
/// 错误色 8% 叠加,用作错误提示条 / 错误输入框的底色。
|
||||
static const Color dangerFaint = Color(0x14E5484D);
|
||||
|
||||
// ---- 阴影 ----
|
||||
|
||||
/// 卡片默认阴影(`--shadow`)。
|
||||
|
||||
@@ -9,3 +9,9 @@ String firstChar(String text, {String fallback = '·'}) {
|
||||
}
|
||||
return String.fromCharCode(text.runes.first);
|
||||
}
|
||||
|
||||
/// 字符数(不是字节数)。
|
||||
///
|
||||
/// 和后端 `util::char_count` 的口径一致:先去掉首尾空白,再数字符。
|
||||
/// 中文一个字算一个,所以「最多 60 个字符」用 `runes.length` 而不是 `length`。
|
||||
int charCount(String text) => text.trim().runes.length;
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ class LinkItem {
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
this.categoryId = '',
|
||||
this.description = '',
|
||||
this.editable = false,
|
||||
});
|
||||
|
||||
/// 从 JSON 构造(将来接后端接口时使用)。
|
||||
@@ -15,7 +17,9 @@ class LinkItem {
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
url: json['url'] as String,
|
||||
categoryId: json['categoryId'] as String? ?? '',
|
||||
description: json['description'] as String? ?? '',
|
||||
editable: json['editable'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,9 +32,18 @@ class LinkItem {
|
||||
/// 跳转地址。
|
||||
final String url;
|
||||
|
||||
/// 所属分组 id。编辑时要靠它反查出分组名,才能把表单预填对。
|
||||
final String categoryId;
|
||||
|
||||
/// 一句话描述。
|
||||
final String description;
|
||||
|
||||
/// 这条能不能改。
|
||||
///
|
||||
/// 后端按 `source != "system"` 推导出 `editable`:系统内置的条目只读,
|
||||
/// 卡片上不会出现编辑/删除;用户提交和接口推送进来的可以改、可以删。
|
||||
final bool editable;
|
||||
|
||||
/// 卡片图标显示的首字符。
|
||||
String get initial => firstChar(title).toUpperCase();
|
||||
|
||||
@@ -42,7 +55,9 @@ class LinkItem {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'url': url,
|
||||
'categoryId': categoryId,
|
||||
'description': description,
|
||||
'editable': editable,
|
||||
};
|
||||
|
||||
@override
|
||||
@@ -52,11 +67,14 @@ class LinkItem {
|
||||
other.id == id &&
|
||||
other.title == title &&
|
||||
other.url == url &&
|
||||
other.description == description;
|
||||
other.categoryId == categoryId &&
|
||||
other.description == description &&
|
||||
other.editable == editable;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, title, url, description);
|
||||
int get hashCode =>
|
||||
Object.hash(id, title, url, categoryId, description, editable);
|
||||
|
||||
@override
|
||||
String toString() => 'LinkItem(id: $id, title: $title)';
|
||||
|
||||
@@ -10,6 +10,7 @@ class NavNode {
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.children = const <NavNode>[],
|
||||
this.editable = false,
|
||||
});
|
||||
|
||||
/// 唯一标识;叶子节点同时也是内容区的锚点 id。
|
||||
@@ -21,6 +22,12 @@ class NavNode {
|
||||
/// 子节点;为空表示这是一个直接跳转的条目。
|
||||
final List<NavNode> children;
|
||||
|
||||
/// 这个分组是不是用户创建的(后端返回的 `editable`)。
|
||||
///
|
||||
/// 提交表单据此给出「已有的用户分组」建议:内置分组不可选,
|
||||
/// 因为提交的内容永远进不了内置分组。
|
||||
final bool editable;
|
||||
|
||||
/// 是否是可展开的分组。
|
||||
bool get isGroup => children.isNotEmpty;
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/// 提交一个网站时填的内容。
|
||||
///
|
||||
/// 和后端 `POST /api/sites` 的入参一一对应。**没有 `categoryId`**:
|
||||
/// 提交方只能给分组名,由后端在用户分组里查找或新建。
|
||||
class SubmitSiteDraft {
|
||||
const SubmitSiteDraft({
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.group,
|
||||
this.description = '',
|
||||
this.subGroup = '',
|
||||
this.submittedBy = '',
|
||||
});
|
||||
|
||||
/// 网站名称。
|
||||
final String title;
|
||||
|
||||
/// 网站地址。
|
||||
final String url;
|
||||
|
||||
/// 一级分组名。
|
||||
final String group;
|
||||
|
||||
/// 一句话描述。
|
||||
final String description;
|
||||
|
||||
/// 子分组名,可为空。
|
||||
final String subGroup;
|
||||
|
||||
/// 提交者的称呼或邮箱。
|
||||
final String submittedBy;
|
||||
|
||||
/// 序列化成后端要的 JSON(camelCase,和后端 `CreateSiteRequest` 对齐)。
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'title': title,
|
||||
'url': url,
|
||||
'description': description,
|
||||
'group': group,
|
||||
'subGroup': subGroup,
|
||||
'submittedBy': submittedBy,
|
||||
};
|
||||
}
|
||||
@@ -5,21 +5,24 @@ import 'package:flutter/material.dart';
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/services/url_opener.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_text_styles.dart';
|
||||
import '../../data/home_repository.dart';
|
||||
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 '../widgets/back_to_top_button.dart';
|
||||
import '../widgets/confirm_dialog.dart';
|
||||
import '../widgets/link_section_view.dart';
|
||||
import '../widgets/sidebar_nav.dart';
|
||||
import '../widgets/site_form_dialog.dart';
|
||||
import '../widgets/top_bar.dart';
|
||||
|
||||
/// 首页:可折叠侧栏 + 顶部控制栏 + 分组卡片网格。
|
||||
///
|
||||
/// 完全按项目根目录的 `index3.html` 还原:结构、样式、交互都以那份文件为准,
|
||||
/// 不额外增加原型里没有的行为。原型里没有的东西也照样不实现——
|
||||
/// 搜索框不参与过滤(原型没有对应脚本)、`active` 固定写在第一个导航条目上、
|
||||
/// 没有对应锚点的导航项点击后不会有任何反应。
|
||||
/// 数据来自后端 `GET /api/nav`,所以页面要表达三种状态:加载中、加载失败
|
||||
/// (带重试)、加载完成。布局与交互仍然以项目根目录的 `index3.html` 为准。
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({
|
||||
super.key,
|
||||
@@ -27,7 +30,7 @@ class HomePage extends StatefulWidget {
|
||||
required this.urlOpener,
|
||||
});
|
||||
|
||||
/// 数据来源(当前是 mock 实现)。
|
||||
/// 数据来源(当前是 HTTP 实现,测试里注入 mock)。
|
||||
final HomeRepository repository;
|
||||
|
||||
/// 打开链接的能力(当前是 Web 实现)。
|
||||
@@ -38,13 +41,12 @@ class HomePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
/// 首页数据只在初始化时取一次。
|
||||
late final HomeData _data = widget.repository.fetchHomeData();
|
||||
HomeData? _data;
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
/// 有对应锚点的分区,用于「点导航滚动过去」。
|
||||
late final Map<String, GlobalKey> _sectionKeys = <String, GlobalKey>{
|
||||
for (final LinkSection section in _data.sections) section.id: GlobalKey(),
|
||||
};
|
||||
/// 有对应分区的分组,用于「点导航滚动过去」;数据到了之后才建。
|
||||
Map<String, GlobalKey> _sectionKeys = <String, GlobalKey>{};
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
@@ -55,6 +57,12 @@ class _HomePageState extends State<HomePage> {
|
||||
/// 侧栏是否折叠。原型初始是展开的(body 上没有 sidebar-collapsed)。
|
||||
bool _collapsed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
@@ -62,6 +70,48 @@ class _HomePageState extends State<HomePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 向后端取一次数据。失败时把说明留在 `_error` 里给用户看,并允许重试。
|
||||
///
|
||||
/// [showLoading] 为 false 时是「静默刷新」:页面继续显示旧内容,
|
||||
/// 失败了只提示一句,不会把已经渲染好的页面换成错误页。
|
||||
Future<void> _load({bool showLoading = true}) async {
|
||||
if (showLoading) {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
final HomeData data = await widget.repository.fetchHomeData();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_data = data;
|
||||
_loading = false;
|
||||
_error = null;
|
||||
_sectionKeys = <String, GlobalKey>{
|
||||
for (final LinkSection section in data.sections)
|
||||
section.id: GlobalKey(),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (showLoading) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = error.toString();
|
||||
});
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('刷新失败:$error')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 交互 ----
|
||||
|
||||
/// `#toggleBtn`:切换 `sidebar-collapsed`。
|
||||
@@ -83,10 +133,10 @@ class _HomePageState extends State<HomePage> {
|
||||
});
|
||||
}
|
||||
|
||||
/// 点导航叶子:滚到对应锚点。
|
||||
/// 点导航叶子:滚到对应分区。
|
||||
///
|
||||
/// 原型里 `#icon`、`#stock`、`#dev`、`#api` 没有对应元素,
|
||||
/// 对应 `<a href="#...">` 点了不会有任何反应,这里保持一致。
|
||||
/// 后端允许存在没有网站的分组,这类分组在内容区没有对应元素,
|
||||
/// 点了不会有任何反应——和原型里那几个空锚点一致。
|
||||
void _goToSection(String sectionId) {
|
||||
final BuildContext? sectionContext = _sectionKeys[sectionId]?.currentContext;
|
||||
if (sectionContext == null) {
|
||||
@@ -103,6 +153,102 @@ class _HomePageState extends State<HomePage> {
|
||||
|
||||
void _openItem(LinkItem item) => widget.urlOpener.open(item.url);
|
||||
|
||||
/// 打开「提交网站」表单;提交成功后静默刷新一次,让新站点立刻出现在侧栏里。
|
||||
Future<void> _openSubmitDialog(HomeData data) async {
|
||||
final bool saved = await showSiteFormDialog(
|
||||
context: context,
|
||||
navNodes: data.navNodes,
|
||||
heading: '提交网站',
|
||||
onSubmit: (SubmitSiteDraft draft) => widget.repository.submitSite(draft),
|
||||
);
|
||||
if (!saved || !mounted) {
|
||||
return;
|
||||
}
|
||||
_toast('已提交,感谢');
|
||||
await _load(showLoading: false);
|
||||
}
|
||||
|
||||
/// 编辑一个用户提交的站点:表单预填现有内容,保存走 `PATCH /api/sites/{id}`。
|
||||
Future<void> _openEditDialog(HomeData data, LinkItem item) async {
|
||||
final ({String group, String subGroup})? path = _groupPathOf(
|
||||
data.navNodes,
|
||||
item.categoryId,
|
||||
);
|
||||
final bool saved = await showSiteFormDialog(
|
||||
context: context,
|
||||
navNodes: data.navNodes,
|
||||
heading: '编辑网站',
|
||||
initial: SubmitSiteDraft(
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
description: item.description,
|
||||
group: path?.group ?? '',
|
||||
subGroup: path?.subGroup ?? '',
|
||||
),
|
||||
onSubmit: (SubmitSiteDraft draft) =>
|
||||
widget.repository.updateSite(item.id, draft),
|
||||
);
|
||||
if (!saved || !mounted) {
|
||||
return;
|
||||
}
|
||||
_toast('已保存');
|
||||
await _load(showLoading: false);
|
||||
}
|
||||
|
||||
/// 删除一个用户提交的站点:先确认,再走 `DELETE /api/sites/{id}`。
|
||||
Future<void> _confirmDelete(LinkItem item) async {
|
||||
final bool confirmed = await showConfirmDialog(
|
||||
context: context,
|
||||
title: '删除网站',
|
||||
message: '确定要删除「${item.title}」吗?删掉之后不能恢复。',
|
||||
confirmLabel: '删除',
|
||||
danger: true,
|
||||
);
|
||||
if (!confirmed || !mounted) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await widget.repository.deleteSite(item.id);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
_toast('删除失败:$error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_toast('已删除');
|
||||
await _load(showLoading: false);
|
||||
}
|
||||
|
||||
void _toast(String message) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
/// 从侧栏树里反查某个分组 id 的「一级分组 + 子分组」名字。
|
||||
///
|
||||
/// 后端只按分组名收更新,所以编辑时必须把 id 还原成名字;
|
||||
/// 层级最多两级,这里只查两轮。
|
||||
({String group, String subGroup})? _groupPathOf(
|
||||
List<NavNode> nodes,
|
||||
String categoryId,
|
||||
) {
|
||||
for (final NavNode root in nodes) {
|
||||
if (root.id == categoryId) {
|
||||
return (group: root.title, subGroup: '');
|
||||
}
|
||||
for (final NavNode child in root.children) {
|
||||
if (child.id == categoryId) {
|
||||
return (group: root.title, subGroup: child.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _backToTop() {
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
@@ -117,39 +263,52 @@ class _HomePageState extends State<HomePage> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
SidebarNav(
|
||||
siteName: _data.siteName,
|
||||
navNodes: _data.navNodes,
|
||||
// 原型把 active 写死在第一个条目上,切换页面也不变。
|
||||
activeSectionId: _data.activeSectionId,
|
||||
collapsed: _collapsed,
|
||||
openGroupIds: _openGroupIds,
|
||||
onToggleGroup: _toggleGroup,
|
||||
onSectionSelected: _goToSection,
|
||||
// 原型指向不存在的 #submit,点了没有任何反应。
|
||||
onSubmitSite: () {},
|
||||
// 补上这行,将页面的切换折叠方法传进去
|
||||
onToggleCollapse: _toggleSidebar,
|
||||
),
|
||||
Expanded(child: _buildMain()),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: AppSizes.backToTopOffset,
|
||||
bottom: AppSizes.backToTopOffset,
|
||||
child: BackToTopButton(onTap: _backToTop),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMain() {
|
||||
Widget _buildBody() {
|
||||
if (_loading) {
|
||||
return const _StatusView(message: '正在加载…', loading: true);
|
||||
}
|
||||
final String? error = _error;
|
||||
if (error != null) {
|
||||
return _StatusView(title: '加载失败', message: error, onRetry: _load);
|
||||
}
|
||||
return _buildLoaded(_data!);
|
||||
}
|
||||
|
||||
Widget _buildLoaded(HomeData data) {
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
SidebarNav(
|
||||
siteName: data.siteName,
|
||||
navNodes: data.navNodes,
|
||||
// 原型把 active 写死在第一个条目上。
|
||||
activeSectionId: data.activeSectionId,
|
||||
collapsed: _collapsed,
|
||||
openGroupIds: _openGroupIds,
|
||||
onToggleGroup: _toggleGroup,
|
||||
onSectionSelected: _goToSection,
|
||||
onSubmitSite: () => unawaited(_openSubmitDialog(data)),
|
||||
onToggleCollapse: _toggleSidebar,
|
||||
),
|
||||
Expanded(child: _buildMain(data)),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: AppSizes.backToTopOffset,
|
||||
bottom: AppSizes.backToTopOffset,
|
||||
child: BackToTopButton(onTap: _backToTop),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMain(HomeData data) {
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
@@ -163,11 +322,11 @@ class _HomePageState extends State<HomePage> {
|
||||
children: <Widget>[
|
||||
TopBar(
|
||||
searchController: _searchController,
|
||||
searchHint: _data.searchHint,
|
||||
searchHint: data.searchHint,
|
||||
sidebarCollapsed: _collapsed,
|
||||
onToggleSidebar: _toggleSidebar,
|
||||
),
|
||||
for (final LinkSection section in _data.sections)
|
||||
for (final LinkSection section in data.sections)
|
||||
Padding(
|
||||
// 原型里每个 .grid 都带 margin-bottom: 36px。
|
||||
padding: const EdgeInsets.only(bottom: AppSizes.sectionGap),
|
||||
@@ -175,6 +334,9 @@ class _HomePageState extends State<HomePage> {
|
||||
key: _sectionKeys[section.id],
|
||||
section: section,
|
||||
onOpen: _openItem,
|
||||
onEdit: (LinkItem item) =>
|
||||
unawaited(_openEditDialog(data, item)),
|
||||
onDelete: (LinkItem item) => unawaited(_confirmDelete(item)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -182,3 +344,92 @@ class _HomePageState extends State<HomePage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载中 / 加载失败时占满内容区的提示。
|
||||
class _StatusView extends StatelessWidget {
|
||||
const _StatusView({
|
||||
required this.message,
|
||||
this.title,
|
||||
this.loading = false,
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final String? title;
|
||||
final bool loading;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSizes.contentPaddingHorizontal),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
if (loading) ...<Widget>[
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(height: AppSizes.gridGap),
|
||||
],
|
||||
if (title != null) ...<Widget>[
|
||||
Text(title!, style: AppTextStyles.emptyTitle),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
style: AppTextStyles.emptyMessage,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (onRetry != null) ...<Widget>[
|
||||
const SizedBox(height: AppSizes.gridGap),
|
||||
_RetryButton(onTap: onRetry!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 「重试」按钮:跟前端其它次级按钮同一套描边样式。
|
||||
class _RetryButton extends StatefulWidget {
|
||||
const _RetryButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_RetryButton> createState() => _RetryButtonState();
|
||||
}
|
||||
|
||||
class _RetryButtonState extends State<_RetryButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? AppColors.primaryFaint : Colors.transparent,
|
||||
border: Border.all(color: AppColors.primary),
|
||||
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
|
||||
),
|
||||
child: Text(
|
||||
'重试',
|
||||
style: AppTextStyles.navItem.copyWith(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_text_styles.dart';
|
||||
import 'dialog_buttons.dart';
|
||||
|
||||
/// 通用确认框,返回 true 表示用户点了确认。
|
||||
///
|
||||
/// [danger] 为 true 时确认按钮用错误色,用在删除这类不可逆操作上。
|
||||
Future<bool> showConfirmDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
required String confirmLabel,
|
||||
String cancelLabel = '取消',
|
||||
bool danger = false,
|
||||
}) async {
|
||||
final bool? confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierColor: const Color(0x73111827),
|
||||
builder: (BuildContext context) => _ConfirmDialog(
|
||||
title: title,
|
||||
message: message,
|
||||
confirmLabel: confirmLabel,
|
||||
cancelLabel: cancelLabel,
|
||||
danger: danger,
|
||||
),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
|
||||
class _ConfirmDialog extends StatelessWidget {
|
||||
const _ConfirmDialog({
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.confirmLabel,
|
||||
required this.cancelLabel,
|
||||
required this.danger,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final String confirmLabel;
|
||||
final String cancelLabel;
|
||||
final bool danger;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
insetPadding: const EdgeInsets.all(AppSizes.dialogPadding),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(AppSizes.cardRadius),
|
||||
boxShadow: AppColors.cardShadowHover,
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSizes.dialogPadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(title, style: AppTextStyles.sectionTitle),
|
||||
const SizedBox(height: AppSizes.spaceSm),
|
||||
Text(message, style: AppTextStyles.emptyMessage),
|
||||
const SizedBox(height: AppSizes.spaceXl),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
DialogGhostButton(
|
||||
label: cancelLabel,
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
const SizedBox(width: AppSizes.spaceMd),
|
||||
DialogPrimaryButton(
|
||||
label: confirmLabel,
|
||||
danger: danger,
|
||||
onTap: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_text_styles.dart';
|
||||
|
||||
/// 对话框里的主按钮:实底、8px 圆角(和侧栏「提交网站」一致)。
|
||||
///
|
||||
/// [danger] 为 true 时用错误色,用在「删除」这类不可逆操作上。
|
||||
class DialogPrimaryButton extends StatefulWidget {
|
||||
const DialogPrimaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.danger = false,
|
||||
});
|
||||
|
||||
/// 按钮文字。
|
||||
final String label;
|
||||
|
||||
/// 点击回调;为 null 表示禁用。
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 是否用错误色(删除类操作)。
|
||||
final bool danger;
|
||||
|
||||
@override
|
||||
State<DialogPrimaryButton> createState() => _DialogPrimaryButtonState();
|
||||
}
|
||||
|
||||
class _DialogPrimaryButtonState extends State<DialogPrimaryButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool enabled = widget.onTap != null;
|
||||
final Color background = widget.danger
|
||||
? AppColors.danger
|
||||
: AppColors.primary;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSizes.cardPaddingHorizontal,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? background : AppColors.border,
|
||||
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
|
||||
),
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: AppTextStyles.submitButton.copyWith(
|
||||
color: enabled ? AppColors.surface : AppColors.textSecondary,
|
||||
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 对话框里的次级按钮:描边、透明底。
|
||||
class DialogGhostButton extends StatefulWidget {
|
||||
const DialogGhostButton({super.key, required this.label, required this.onTap});
|
||||
|
||||
/// 按钮文字。
|
||||
final String label;
|
||||
|
||||
/// 点击回调;为 null 表示禁用。
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
State<DialogGhostButton> createState() => _DialogGhostButtonState();
|
||||
}
|
||||
|
||||
class _DialogGhostButtonState extends State<DialogGhostButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool enabled = widget.onTap != null;
|
||||
return MouseRegion(
|
||||
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSizes.cardPaddingHorizontal,
|
||||
vertical: 9,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? AppColors.primaryFaint : Colors.transparent,
|
||||
border: Border.all(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
|
||||
),
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: AppTextStyles.submitButton.copyWith(
|
||||
color: enabled ? AppColors.textSecondary : AppColors.navArrow,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,19 @@ import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_text_styles.dart';
|
||||
import '../../domain/models/link_item.dart';
|
||||
|
||||
/// 站点卡片,对应 `index.html` 里的 `.card`。
|
||||
/// 站点卡片,对应 `index3.html` 里的 `.card`。
|
||||
///
|
||||
/// 结构是「36px 的字母方块 + 名称 + 描述」,悬停时上移 2px 并加强阴影。
|
||||
/// 用户提交 / 接口推送进来的条目(`item.editable`)在悬停时右上角会露出
|
||||
/// 「编辑 / 删除」;系统内置的条目没有这两个按钮——后端也不允许改。
|
||||
class LinkCard extends StatefulWidget {
|
||||
const LinkCard({super.key, required this.item, required this.onOpen});
|
||||
const LinkCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onOpen,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
/// 站点数据。
|
||||
final LinkItem item;
|
||||
@@ -17,6 +25,12 @@ class LinkCard extends StatefulWidget {
|
||||
/// 打开回调。
|
||||
final VoidCallback onOpen;
|
||||
|
||||
/// 编辑回调;为空或条目不可修改时不显示编辑按钮。
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
/// 删除回调;为空或条目不可修改时不显示删除按钮。
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
State<LinkCard> createState() => _LinkCardState();
|
||||
}
|
||||
@@ -26,6 +40,8 @@ class _LinkCardState extends State<LinkCard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool canManage = widget.item.editable;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
@@ -53,31 +69,68 @@ class _LinkCardState extends State<LinkCard> {
|
||||
? AppColors.cardShadowHover
|
||||
: AppColors.cardShadow,
|
||||
),
|
||||
child: Row(
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
_CardIcon(initial: widget.item.initial),
|
||||
const SizedBox(width: AppSizes.cardIconGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
widget.item.title,
|
||||
style: AppTextStyles.cardTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_CardIcon(initial: widget.item.initial),
|
||||
const SizedBox(width: AppSizes.cardIconGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
widget.item.title,
|
||||
style: AppTextStyles.cardTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSizes.cardTitleGap),
|
||||
Text(
|
||||
widget.item.description,
|
||||
style: AppTextStyles.cardDesc,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSizes.cardTitleGap),
|
||||
Text(
|
||||
widget.item.description,
|
||||
style: AppTextStyles.cardDesc,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 悬停才露出来,且不占正文的位置:不给可修改的卡片留常驻空白,
|
||||
// 也不让鼠标移上去时文字宽度跳一下。
|
||||
if (canManage)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: AnimatedOpacity(
|
||||
duration: AppSizes.transition,
|
||||
opacity: _hovered ? 1 : 0,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
if (widget.onEdit != null)
|
||||
_CardAction(
|
||||
icon: '✎',
|
||||
tooltip: '编辑',
|
||||
background: AppColors.surface,
|
||||
onTap: widget.onEdit!,
|
||||
),
|
||||
if (widget.onEdit != null && widget.onDelete != null)
|
||||
const SizedBox(width: AppSizes.spaceXs),
|
||||
if (widget.onDelete != null)
|
||||
_CardAction(
|
||||
icon: '✕',
|
||||
tooltip: '删除',
|
||||
background: AppColors.surface,
|
||||
danger: true,
|
||||
onTap: widget.onDelete!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -86,6 +139,69 @@ class _LinkCardState extends State<LinkCard> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片右上角的小动作按钮(编辑 / 删除)。
|
||||
class _CardAction extends StatefulWidget {
|
||||
const _CardAction({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.background,
|
||||
required this.onTap,
|
||||
this.danger = false,
|
||||
});
|
||||
|
||||
final String icon;
|
||||
final String tooltip;
|
||||
final Color background;
|
||||
final VoidCallback onTap;
|
||||
final bool danger;
|
||||
|
||||
@override
|
||||
State<_CardAction> createState() => _CardActionState();
|
||||
}
|
||||
|
||||
class _CardActionState extends State<_CardAction> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color foreground = _hovered
|
||||
? (widget.danger ? AppColors.danger : AppColors.primary)
|
||||
: AppColors.navArrow;
|
||||
|
||||
return Tooltip(
|
||||
message: widget.tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
// 只处理自己的点击,不要让卡片把事件吃掉(否则会去打开网站)。
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
width: AppSizes.iconButtonSize,
|
||||
height: AppSizes.iconButtonSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? (widget.danger
|
||||
? AppColors.dangerFaint
|
||||
: AppColors.primaryFaint)
|
||||
: widget.background,
|
||||
border: Border.all(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
|
||||
),
|
||||
child: Text(
|
||||
widget.icon,
|
||||
style: AppTextStyles.cardDesc.copyWith(color: foreground),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片左侧的字母方块(`.card-icon`)。
|
||||
class _CardIcon extends StatelessWidget {
|
||||
const _CardIcon({required this.initial});
|
||||
|
||||
@@ -5,12 +5,6 @@ import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_text_styles.dart';
|
||||
|
||||
/// 搜索框。
|
||||
///
|
||||
/// 对应 `index3.html` 的 `.search-box`:固定 44px 高、白色底、1px 描边、
|
||||
/// 12px 圆角,聚焦时边框变主色并外扩 3px 的浅色光晕。
|
||||
///
|
||||
/// 用 [FocusNode] 而不是 `InputDecoration` 的 `focusedBorder`:
|
||||
/// 那条光晕是「向外扩散」的矩形阴影,靠边框宽度画不出来。
|
||||
class LinkSearchField extends StatefulWidget {
|
||||
const LinkSearchField({
|
||||
super.key,
|
||||
@@ -18,10 +12,7 @@ class LinkSearchField extends StatefulWidget {
|
||||
required this.hintText,
|
||||
});
|
||||
|
||||
/// 文本控制器。
|
||||
final TextEditingController controller;
|
||||
|
||||
/// 占位文案。
|
||||
final String hintText;
|
||||
|
||||
@override
|
||||
@@ -54,42 +45,52 @@ class _LinkSearchFieldState extends State<LinkSearchField> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
// `.search-box { transition: border-color .2s, box-shadow .2s }`。
|
||||
curve: Curves.ease,
|
||||
height: AppSizes.searchHeight,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSizes.searchPaddingHorizontal,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(
|
||||
color: _focused ? AppColors.primary : AppColors.border,
|
||||
return GestureDetector(
|
||||
// 输入框只占中间一行的高度,点上下留白也让它聚焦——
|
||||
// 原型里整个 44px 高的盒子就是一个 <input>。
|
||||
onTap: _focusNode.requestFocus,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
curve: Curves.ease,
|
||||
height: AppSizes.searchHeight,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSizes.searchPaddingHorizontal,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppSizes.searchRadius),
|
||||
// 原型 `.search-box:focus` 的 box-shadow 会整体覆盖掉默认阴影,
|
||||
// 聚焦时只剩这一圈光晕,不是「默认阴影 + 光晕」。
|
||||
boxShadow: <BoxShadow>[
|
||||
if (_focused)
|
||||
const BoxShadow(color: AppColors.focusRing, spreadRadius: 3)
|
||||
else
|
||||
...AppColors.cardShadow,
|
||||
],
|
||||
),
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
style: AppTextStyles.searchInput,
|
||||
cursorColor: AppColors.primary,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: widget.hintText,
|
||||
hintStyle: AppTextStyles.searchHint,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(
|
||||
color: _focused ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppSizes.searchRadius),
|
||||
boxShadow: <BoxShadow>[
|
||||
if (_focused)
|
||||
const BoxShadow(color: AppColors.focusRing, spreadRadius: 3)
|
||||
else
|
||||
...AppColors.cardShadow,
|
||||
],
|
||||
),
|
||||
// 垂直居中不要靠固定的 contentPadding。
|
||||
// 那个写法只在「字体行高 + 上下 padding 正好等于内容区高度」时成立,
|
||||
// 换一个字体(浏览器里的系统字体行高通常更高)就会被挤偏。
|
||||
// 这里让 TextField 按自身行高确定高度,再用 Align 把这一行放进
|
||||
// 44px 的内容区里居中——和浏览器渲染单行 <input> 的方式一致,
|
||||
// 与字体行高无关。
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
style: AppTextStyles.searchInput,
|
||||
cursorColor: AppColors.primary,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: widget.hintText,
|
||||
hintStyle: AppTextStyles.searchHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,8 @@ class LinkSectionView extends StatelessWidget {
|
||||
super.key,
|
||||
required this.section,
|
||||
required this.onOpen,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
/// 分组数据。
|
||||
@@ -22,6 +24,12 @@ class LinkSectionView extends StatelessWidget {
|
||||
/// 打开某个站点。
|
||||
final ValueChanged<LinkItem> onOpen;
|
||||
|
||||
/// 编辑某个站点;为空时卡片不显示编辑入口。
|
||||
final ValueChanged<LinkItem>? onEdit;
|
||||
|
||||
/// 删除某个站点;为空时卡片不显示删除入口。
|
||||
final ValueChanged<LinkItem>? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
@@ -47,7 +55,12 @@ class LinkSectionView extends StatelessWidget {
|
||||
for (final LinkItem item in section.items)
|
||||
SizedBox(
|
||||
width: itemWidth,
|
||||
child: LinkCard(item: item, onOpen: () => onOpen(item)),
|
||||
child: LinkCard(
|
||||
item: item,
|
||||
onOpen: () => onOpen(item),
|
||||
onEdit: onEdit == null ? null : () => onEdit!(item),
|
||||
onDelete: onDelete == null ? null : () => onDelete!(item),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_text_styles.dart';
|
||||
import '../../../../core/utils/text.dart';
|
||||
import '../../domain/models/nav_node.dart';
|
||||
import '../../domain/models/submit_site_draft.dart';
|
||||
import 'dialog_buttons.dart';
|
||||
|
||||
/// 打开网站表单对话框,返回 true 表示保存成功了。
|
||||
///
|
||||
/// 新增和编辑共用这一个表单:新增时 [onSubmit] 打 `POST /api/sites`,
|
||||
/// 编辑时打 `PATCH /api/sites/{id}`,并用 [initial] 预填现有内容。
|
||||
Future<bool> showSiteFormDialog({
|
||||
required BuildContext context,
|
||||
required List<NavNode> navNodes,
|
||||
required String heading,
|
||||
required Future<void> Function(SubmitSiteDraft draft) onSubmit,
|
||||
SubmitSiteDraft? initial,
|
||||
}) async {
|
||||
final bool? submitted = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierColor: const Color(0x73111827),
|
||||
builder: (BuildContext context) => SiteFormDialog(
|
||||
navNodes: navNodes,
|
||||
heading: heading,
|
||||
onSubmit: onSubmit,
|
||||
initial: initial,
|
||||
),
|
||||
);
|
||||
return submitted ?? false;
|
||||
}
|
||||
|
||||
/// 网站表单(新增 / 编辑)。
|
||||
///
|
||||
/// 字段与后端 `POST /api/sites` 一一对应:**没有「选择分组」这个动作**,
|
||||
/// 只有分组名——后端只会在用户创建的分组里查找或新建,提交的内容永远进不了
|
||||
/// 内置分组,所以界面上也要把这件事说清楚,并只给用户分组的建议。
|
||||
class SiteFormDialog extends StatefulWidget {
|
||||
const SiteFormDialog({
|
||||
super.key,
|
||||
required this.navNodes,
|
||||
required this.heading,
|
||||
required this.onSubmit,
|
||||
this.initial,
|
||||
});
|
||||
|
||||
/// 侧栏那棵树,用来给出「已有的用户分组」建议。
|
||||
final List<NavNode> navNodes;
|
||||
|
||||
/// 对话框标题,例如「提交网站」或「编辑网站」。
|
||||
final String heading;
|
||||
|
||||
/// 保存动作。抛异常时表单会留在原地并显示原因。
|
||||
final Future<void> Function(SubmitSiteDraft draft) onSubmit;
|
||||
|
||||
/// 编辑时的现有内容;新增时为 null。
|
||||
final SubmitSiteDraft? initial;
|
||||
|
||||
@override
|
||||
State<SiteFormDialog> createState() => _SiteFormDialogState();
|
||||
}
|
||||
|
||||
class _SiteFormDialogState extends State<SiteFormDialog> {
|
||||
late final TextEditingController _title;
|
||||
late final TextEditingController _url;
|
||||
late final TextEditingController _description;
|
||||
late final TextEditingController _group;
|
||||
late final TextEditingController _subGroup;
|
||||
late final TextEditingController _submittedBy;
|
||||
|
||||
Map<String, String?> _errors = <String, String?>{};
|
||||
String? _serverError;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final SubmitSiteDraft? initial = widget.initial;
|
||||
_title = TextEditingController(text: initial?.title ?? '');
|
||||
_url = TextEditingController(text: initial?.url ?? '');
|
||||
_description = TextEditingController(text: initial?.description ?? '');
|
||||
_group = TextEditingController(text: initial?.group ?? '');
|
||||
_subGroup = TextEditingController(text: initial?.subGroup ?? '');
|
||||
_submittedBy = TextEditingController(text: initial?.submittedBy ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_url.dispose();
|
||||
_description.dispose();
|
||||
_group.dispose();
|
||||
_subGroup.dispose();
|
||||
_submittedBy.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 已有的用户分组名(内置分组不出现在这里——选了也用不了)。
|
||||
List<String> get _groupSuggestions => widget.navNodes
|
||||
.where((NavNode node) => node.editable)
|
||||
.map((NavNode node) => node.title)
|
||||
.toList(growable: false);
|
||||
|
||||
/// 当前填的分组下已有的用户子分组。
|
||||
List<String> get _subGroupSuggestions {
|
||||
final String group = _group.text.trim();
|
||||
for (final NavNode node in widget.navNodes) {
|
||||
if (node.editable && node.title == group) {
|
||||
return node.children
|
||||
.where((NavNode child) => child.editable)
|
||||
.map((NavNode child) => child.title)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
return const <String>[];
|
||||
}
|
||||
|
||||
/// 某个字段的内容变了:清掉它上一次的错误,让用户边改边看到反馈。
|
||||
void _clearError(String field) {
|
||||
if (_errors[field] == null && _serverError == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_errors = <String, String?>{..._errors, field: null};
|
||||
_serverError = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final Map<String, String?> errors = <String, String?>{
|
||||
'title': _validateTitle(_title.text),
|
||||
'url': _validateUrl(_url.text),
|
||||
'description': _validateDescription(_description.text),
|
||||
'group': _validateGroup(_group.text),
|
||||
'subGroup': _validateSubGroup(_subGroup.text),
|
||||
'submittedBy': _validateSubmittedBy(_submittedBy.text),
|
||||
};
|
||||
setState(() {
|
||||
_errors = errors;
|
||||
_serverError = null;
|
||||
});
|
||||
if (errors.values.any((String? message) => message != null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await widget.onSubmit(
|
||||
SubmitSiteDraft(
|
||||
title: _title.text.trim(),
|
||||
url: _url.text.trim(),
|
||||
description: _description.text.trim(),
|
||||
group: _group.text.trim(),
|
||||
subGroup: _subGroup.text.trim(),
|
||||
submittedBy: _submittedBy.text.trim(),
|
||||
),
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(true);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
// 后端的中文说明原样显示,用户能看懂哪里不对。
|
||||
setState(() {
|
||||
_submitting = false;
|
||||
_serverError = error.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
// 请求还在路上时不允许关闭:关了界面请求也回不来,用户会以为丢了。
|
||||
canPop: !_submitting,
|
||||
child: Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
insetPadding: const EdgeInsets.all(AppSizes.dialogPadding),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: AppSizes.dialogMaxWidth),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(AppSizes.cardRadius),
|
||||
boxShadow: AppColors.cardShadowHover,
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSizes.dialogPadding),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
_Header(
|
||||
heading: widget.heading,
|
||||
submitting: _submitting,
|
||||
onClose: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
if (_serverError != null) ...<Widget>[
|
||||
const SizedBox(height: AppSizes.spaceLg),
|
||||
_ErrorBanner(message: _serverError!),
|
||||
],
|
||||
const SizedBox(height: AppSizes.spaceLg),
|
||||
_InputBox(
|
||||
label: '网站名称',
|
||||
controller: _title,
|
||||
hint: '例如:GitHub',
|
||||
enabled: !_submitting,
|
||||
error: _errors['title'],
|
||||
onChanged: () => _clearError('title'),
|
||||
),
|
||||
const SizedBox(height: AppSizes.spaceLg),
|
||||
_InputBox(
|
||||
label: '网站地址',
|
||||
controller: _url,
|
||||
hint: 'https://…',
|
||||
enabled: !_submitting,
|
||||
error: _errors['url'],
|
||||
onChanged: () => _clearError('url'),
|
||||
),
|
||||
const SizedBox(height: AppSizes.spaceLg),
|
||||
_InputBox(
|
||||
label: '一句话描述',
|
||||
controller: _description,
|
||||
hint: '这个站点是做什么的(可不填)',
|
||||
enabled: !_submitting,
|
||||
error: _errors['description'],
|
||||
multiline: true,
|
||||
onChanged: () => _clearError('description'),
|
||||
),
|
||||
const SizedBox(height: AppSizes.spaceLg),
|
||||
_InputBox(
|
||||
label: '分组',
|
||||
controller: _group,
|
||||
hint: '例如:AI 工具',
|
||||
enabled: !_submitting,
|
||||
error: _errors['group'],
|
||||
onChanged: () {
|
||||
_clearError('group');
|
||||
// 分组一变,子分组建议跟着换。
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSizes.fieldLabelGap),
|
||||
const Text(
|
||||
'提交的网站会放在你自己的分组里,不会进内置分类。',
|
||||
style: AppTextStyles.emptyMessage,
|
||||
),
|
||||
if (_groupSuggestions.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: AppSizes.spaceSm),
|
||||
_Suggestions(
|
||||
label: '已有的分组',
|
||||
values: _groupSuggestions,
|
||||
enabled: !_submitting,
|
||||
onPick: (String value) {
|
||||
_group.text = value;
|
||||
_clearError('group');
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSizes.spaceLg),
|
||||
_InputBox(
|
||||
label: '子分组',
|
||||
controller: _subGroup,
|
||||
hint: '例如:模型社区(可不填)',
|
||||
enabled: !_submitting,
|
||||
error: _errors['subGroup'],
|
||||
onChanged: () => _clearError('subGroup'),
|
||||
),
|
||||
if (_subGroupSuggestions.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: AppSizes.spaceSm),
|
||||
_Suggestions(
|
||||
label: '已有的子分组',
|
||||
values: _subGroupSuggestions,
|
||||
enabled: !_submitting,
|
||||
onPick: (String value) {
|
||||
_subGroup.text = value;
|
||||
_clearError('subGroup');
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
// 编辑时不让改提交者:后端 PATCH 本来也不接受这个字段。
|
||||
if (widget.initial == null) ...<Widget>[
|
||||
const SizedBox(height: AppSizes.spaceLg),
|
||||
_InputBox(
|
||||
label: '你的称呼或邮箱',
|
||||
controller: _submittedBy,
|
||||
hint: '可不填,方便回访时找你',
|
||||
enabled: !_submitting,
|
||||
error: _errors['submittedBy'],
|
||||
onChanged: () => _clearError('submittedBy'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSizes.spaceXl),
|
||||
_Actions(
|
||||
submitting: _submitting,
|
||||
onCancel: () => Navigator.of(context).pop(false),
|
||||
onSubmit: _submit,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 标题行:标题 + 关闭按钮。
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({
|
||||
required this.heading,
|
||||
required this.submitting,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
final String heading;
|
||||
final bool submitting;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(heading, style: AppTextStyles.sectionTitle),
|
||||
),
|
||||
_IconButton(
|
||||
icon: '×',
|
||||
tooltip: '关闭',
|
||||
onTap: submitting ? null : onClose,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 后端返回的失败说明,放在表单顶部。
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
const _ErrorBanner({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSizes.spaceMd,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.dangerFaint,
|
||||
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
style: AppTextStyles.emptyMessage.copyWith(color: AppColors.danger),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 带标签、错误提示的输入框。
|
||||
///
|
||||
/// 视觉复用搜索框那一套(44px 高 / 12px 圆角 / 聚焦光晕):
|
||||
/// 输入框按自身行高定高,再用 Align 放进盒子里居中,不靠固定 padding 凑高度。
|
||||
class _InputBox extends StatefulWidget {
|
||||
const _InputBox({
|
||||
required this.label,
|
||||
required this.controller,
|
||||
required this.hint,
|
||||
required this.enabled,
|
||||
required this.onChanged,
|
||||
this.error,
|
||||
this.multiline = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String hint;
|
||||
final bool enabled;
|
||||
final VoidCallback onChanged;
|
||||
final String? error;
|
||||
final bool multiline;
|
||||
|
||||
@override
|
||||
State<_InputBox> createState() => _InputBoxState();
|
||||
}
|
||||
|
||||
class _InputBoxState extends State<_InputBox> {
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
bool _focused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.addListener(_handleFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode
|
||||
..removeListener(_handleFocusChange)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleFocusChange() {
|
||||
if (_focusNode.hasFocus != _focused) {
|
||||
setState(() => _focused = _focusNode.hasFocus);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasError = widget.error != null;
|
||||
final Color borderColor = hasError
|
||||
? AppColors.danger
|
||||
: (_focused ? AppColors.primary : AppColors.border);
|
||||
|
||||
final Widget field = TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
onChanged: (_) => widget.onChanged(),
|
||||
maxLines: widget.multiline ? 3 : 1,
|
||||
style: AppTextStyles.searchInput,
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: widget.hint,
|
||||
hintStyle: AppTextStyles.searchHint,
|
||||
),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(widget.label, style: AppTextStyles.emptyMessage),
|
||||
const SizedBox(height: AppSizes.fieldLabelGap),
|
||||
GestureDetector(
|
||||
// 点盒子里输入框以外的留白也能聚焦。
|
||||
onTap: widget.enabled ? _focusNode.requestFocus : null,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
curve: Curves.ease,
|
||||
height: widget.multiline
|
||||
? AppSizes.fieldMultilineHeight
|
||||
: AppSizes.searchHeight,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: AppSizes.cardPaddingHorizontal,
|
||||
vertical: widget.multiline ? 10 : 0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.enabled ? AppColors.surface : AppColors.background,
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: BorderRadius.circular(AppSizes.searchRadius),
|
||||
// 聚焦时只剩光晕(原型 `.search-box:focus` 就是这个行为)。
|
||||
boxShadow: <BoxShadow>[
|
||||
if (_focused)
|
||||
const BoxShadow(color: AppColors.focusRing, spreadRadius: 3)
|
||||
else if (hasError)
|
||||
const BoxShadow(
|
||||
color: AppColors.dangerFaint,
|
||||
spreadRadius: 3,
|
||||
)
|
||||
else
|
||||
...AppColors.cardShadow,
|
||||
],
|
||||
),
|
||||
child: Align(
|
||||
alignment: widget.multiline
|
||||
? Alignment.topLeft
|
||||
: Alignment.centerLeft,
|
||||
child: field,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.error != null) ...<Widget>[
|
||||
const SizedBox(height: AppSizes.spaceXs),
|
||||
Text(
|
||||
widget.error!,
|
||||
style: AppTextStyles.emptyMessage.copyWith(
|
||||
color: AppColors.danger,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一行可点的建议值。
|
||||
class _Suggestions extends StatelessWidget {
|
||||
const _Suggestions({
|
||||
required this.label,
|
||||
required this.values,
|
||||
required this.enabled,
|
||||
required this.onPick,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final List<String> values;
|
||||
final bool enabled;
|
||||
final ValueChanged<String> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 5),
|
||||
child: Text(label, style: AppTextStyles.emptyMessage),
|
||||
),
|
||||
const SizedBox(width: AppSizes.spaceSm),
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: AppSizes.spaceSm,
|
||||
runSpacing: AppSizes.spaceSm,
|
||||
children: <Widget>[
|
||||
for (final String value in values)
|
||||
_SuggestionChip(
|
||||
label: value,
|
||||
onTap: enabled ? () => onPick(value) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestionChip extends StatefulWidget {
|
||||
const _SuggestionChip({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
State<_SuggestionChip> createState() => _SuggestionChipState();
|
||||
}
|
||||
|
||||
class _SuggestionChipState extends State<_SuggestionChip> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? AppColors.primaryFaint : AppColors.iconSurface,
|
||||
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
|
||||
),
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: AppTextStyles.cardDesc.copyWith(
|
||||
color: _hovered ? AppColors.primary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部按钮:左「取消」,右「提交」。
|
||||
class _Actions extends StatelessWidget {
|
||||
const _Actions({
|
||||
required this.submitting,
|
||||
required this.onCancel,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
final bool submitting;
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
DialogGhostButton(label: '取消', onTap: submitting ? null : onCancel),
|
||||
const SizedBox(width: AppSizes.spaceMd),
|
||||
DialogPrimaryButton(
|
||||
label: submitting ? '提交中…' : '提交',
|
||||
onTap: submitting ? null : onSubmit,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconButton extends StatefulWidget {
|
||||
const _IconButton({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
State<_IconButton> createState() => _IconButtonState();
|
||||
}
|
||||
|
||||
class _IconButtonState extends State<_IconButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: widget.tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
width: AppSizes.iconButtonSize,
|
||||
height: AppSizes.iconButtonSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? AppColors.iconSurface : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
|
||||
),
|
||||
child: Text(
|
||||
widget.icon,
|
||||
style: AppTextStyles.cardTitle.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 校验:口径与后端 validate.rs 保持一致 ----
|
||||
|
||||
const int _maxTitle = 60;
|
||||
const int _maxDescription = 120;
|
||||
const int _maxGroup = 30;
|
||||
const int _maxSubmittedBy = 120;
|
||||
|
||||
String? _validateTitle(String value) {
|
||||
if (value.trim().isEmpty) {
|
||||
return '请填写网站名称';
|
||||
}
|
||||
if (charCount(value) > _maxTitle) {
|
||||
return '网站名称最多 $_maxTitle 个字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateUrl(String value) {
|
||||
final String text = value.trim();
|
||||
if (text.isEmpty) {
|
||||
return '请填写网站地址';
|
||||
}
|
||||
final Uri? uri = Uri.tryParse(text);
|
||||
if (uri == null || uri.host.isEmpty) {
|
||||
return '地址要以 http:// 或 https:// 开头';
|
||||
}
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||
return '地址只能是 http:// 或 https://';
|
||||
}
|
||||
if (_isInternalHost(uri.host)) {
|
||||
return '不能填本机或内网地址';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateDescription(String value) {
|
||||
if (charCount(value) > _maxDescription) {
|
||||
return '描述最多 $_maxDescription 个字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateGroup(String value) {
|
||||
final String text = value.trim();
|
||||
if (text.isEmpty) {
|
||||
return '请填写分组名称';
|
||||
}
|
||||
if (charCount(text) > _maxGroup) {
|
||||
return '分组名称最多 $_maxGroup 个字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateSubGroup(String value) {
|
||||
if (charCount(value) > _maxGroup) {
|
||||
return '子分组名称最多 $_maxGroup 个字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateSubmittedBy(String value) {
|
||||
if (charCount(value) > _maxSubmittedBy) {
|
||||
return '最多 $_maxSubmittedBy 个字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 前端只拦最明显的几种内网地址,权威判断在后端。
|
||||
bool _isInternalHost(String host) {
|
||||
final String h = host.toLowerCase();
|
||||
if (h == 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) {
|
||||
return true;
|
||||
}
|
||||
if (h == '0.0.0.0' || h == '::1' || h == '[::1]') {
|
||||
return true;
|
||||
}
|
||||
final List<String> parts = h.split('.');
|
||||
if (parts.length != 4) {
|
||||
return false;
|
||||
}
|
||||
final int? first = int.tryParse(parts[0]);
|
||||
final int? second = int.tryParse(parts[1]);
|
||||
if (first == null || second == null) {
|
||||
return false;
|
||||
}
|
||||
if (first == 127 || first == 10) {
|
||||
return true;
|
||||
}
|
||||
if (first == 192 && second == 168) {
|
||||
return true;
|
||||
}
|
||||
if (first == 172 && second >= 16 && second <= 31) {
|
||||
return true;
|
||||
}
|
||||
return first == 169 && second == 254;
|
||||
}
|
||||
@@ -75,6 +75,22 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -192,6 +208,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -35,6 +35,7 @@ dependencies:
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
web: ^1.0.0
|
||||
http: ^1.6.0
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/features/home/data/home_repository.dart';
|
||||
import 'package:frontend/features/home/data/http_home_repository.dart';
|
||||
import 'package:frontend/features/home/domain/models/home_data.dart';
|
||||
import 'package:frontend/features/home/domain/models/link_section.dart';
|
||||
import 'package:frontend/features/home/domain/models/nav_node.dart';
|
||||
import 'package:frontend/features/home/domain/models/submit_site_draft.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
/// 和后端真实响应同构:两个一级分组,其中一个带子分组,还留一个空分组。
|
||||
const String _navJson = '''
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"id": "common",
|
||||
"title": "常用推荐",
|
||||
"source": "system",
|
||||
"editable": false,
|
||||
"sites": [
|
||||
{ "id": "github", "title": "GitHub", "url": "https://github.com",
|
||||
"description": "代码托管与开源社区", "categoryId": "common",
|
||||
"source": "system", "order": 10, "editable": false,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": "" },
|
||||
{ "id": "zhihu", "title": "知乎", "url": "https://www.zhihu.com",
|
||||
"description": "深度内容问答社区", "categoryId": "common",
|
||||
"source": "system", "order": 20, "editable": false,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": "" }
|
||||
],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "materials",
|
||||
"title": "素材资源",
|
||||
"source": "system",
|
||||
"editable": false,
|
||||
"sites": [],
|
||||
"children": [
|
||||
{
|
||||
"id": "design",
|
||||
"title": "UI 设计",
|
||||
"source": "system",
|
||||
"editable": false,
|
||||
"sites": [
|
||||
{ "id": "figma", "title": "Figma", "url": "https://www.figma.com",
|
||||
"description": "协同界面设计工具", "categoryId": "design",
|
||||
"source": "system", "order": 10, "editable": false,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": "" }
|
||||
],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "icon",
|
||||
"title": "图标库",
|
||||
"source": "system",
|
||||
"editable": false,
|
||||
"sites": [],
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
''';
|
||||
|
||||
HttpHomeRepository repositoryReturning(
|
||||
String body, {
|
||||
int status = 200,
|
||||
void Function(http.Request request)? onRequest,
|
||||
}) {
|
||||
final MockClient client = MockClient((http.Request request) async {
|
||||
onRequest?.call(request);
|
||||
return http.Response.bytes(utf8.encode(body), status);
|
||||
});
|
||||
return HttpHomeRepository(baseUrl: 'http://api.test', client: client);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('映射后端响应', () {
|
||||
late HomeData data;
|
||||
|
||||
setUp(() async {
|
||||
data = await repositoryReturning(_navJson).fetchHomeData();
|
||||
});
|
||||
|
||||
test('请求打到 /api/nav', () async {
|
||||
Uri? requested;
|
||||
await repositoryReturning(
|
||||
_navJson,
|
||||
onRequest: (http.Request request) => requested = request.url,
|
||||
).fetchHomeData();
|
||||
|
||||
expect(requested.toString(), 'http://api.test/api/nav');
|
||||
});
|
||||
|
||||
test('分组变成侧栏导航树', () {
|
||||
expect(
|
||||
data.navNodes.map((NavNode node) => node.title),
|
||||
<String>['常用推荐', '素材资源'],
|
||||
);
|
||||
expect(data.navNodes[0].isGroup, isFalse, reason: '没有子分组就是直接跳转的条目');
|
||||
expect(data.navNodes[1].isGroup, isTrue);
|
||||
expect(
|
||||
data.navNodes[1].children.map((NavNode node) => node.title),
|
||||
<String>['UI 设计', '图标库'],
|
||||
);
|
||||
});
|
||||
|
||||
test('只有挂着网站的分组才进内容区', () {
|
||||
expect(
|
||||
data.sections.map((LinkSection section) => section.id),
|
||||
<String>['common', 'design'],
|
||||
);
|
||||
// 空分组仍然留在侧栏里,只是点了没有对应内容。
|
||||
expect(data.sectionById('icon'), isNull);
|
||||
expect(
|
||||
data.navLeaves.map((NavNode node) => node.id),
|
||||
contains('icon'),
|
||||
);
|
||||
});
|
||||
|
||||
test('子分组的内容标题带上父级', () {
|
||||
expect(data.sectionById('design')!.title, '素材资源 - UI 设计');
|
||||
expect(data.sectionById('common')!.title, '常用推荐');
|
||||
});
|
||||
|
||||
test('网站按后端给的顺序展开', () {
|
||||
expect(
|
||||
data.sectionById('common')!.items.map((item) => item.title),
|
||||
<String>['GitHub', '知乎'],
|
||||
);
|
||||
expect(data.sectionById('design')!.items.single.url, 'https://www.figma.com');
|
||||
expect(data.sectionById('design')!.items.single.description, '协同界面设计工具');
|
||||
});
|
||||
});
|
||||
|
||||
group('错误处理', () {
|
||||
test('非 200 时带出后端的说明', () async {
|
||||
final HttpHomeRepository repository = repositoryReturning(
|
||||
'{"error":"找不到数据"}',
|
||||
status: 500,
|
||||
);
|
||||
await expectLater(
|
||||
repository.fetchHomeData(),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('找不到数据'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('返回的不是 JSON 时给出明确提示', () async {
|
||||
final HttpHomeRepository repository = repositoryReturning(
|
||||
'<html>502 Bad Gateway</html>',
|
||||
);
|
||||
await expectLater(
|
||||
repository.fetchHomeData(),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('不是合法 JSON'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('缺少 groups 时给出明确提示', () async {
|
||||
final HttpHomeRepository repository = repositoryReturning('{"ok":true}');
|
||||
await expectLater(
|
||||
repository.fetchHomeData(),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('groups'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('连不上后端时给出地址', () async {
|
||||
final MockClient client = MockClient(
|
||||
(http.Request request) async => throw const SocketExceptionStub(),
|
||||
);
|
||||
final HttpHomeRepository repository = HttpHomeRepository(
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
client: client,
|
||||
);
|
||||
await expectLater(
|
||||
repository.fetchHomeData(),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('连不上后端'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('缺字段的条目被跳过,不会让整页失败', () async {
|
||||
final HomeData data = await repositoryReturning('''
|
||||
{"groups":[{"id":"g","title":"G","sites":[
|
||||
{"id":"","title":"没有 id","url":"https://a.com"},
|
||||
{"id":"ok","title":"正常","url":"https://b.com"}
|
||||
],"children":[]}]}
|
||||
''').fetchHomeData();
|
||||
|
||||
expect(data.sectionById('g')!.items, hasLength(1));
|
||||
expect(data.sectionById('g')!.items.single.title, '正常');
|
||||
});
|
||||
});
|
||||
|
||||
group('提交网站', () {
|
||||
const SubmitSiteDraft draft = SubmitSiteDraft(
|
||||
title: 'Hugging Face',
|
||||
url: 'https://huggingface.co',
|
||||
description: '开源模型与数据集社区',
|
||||
group: 'AI 工具',
|
||||
subGroup: '模型社区',
|
||||
submittedBy: '[email protected]',
|
||||
);
|
||||
|
||||
test('POST 到 /api/sites,字段用 camelCase', () async {
|
||||
http.Request? captured;
|
||||
final HttpHomeRepository repository = HttpHomeRepository(
|
||||
baseUrl: 'http://api.test',
|
||||
client: MockClient((http.Request request) async {
|
||||
captured = request;
|
||||
return http.Response.bytes(utf8.encode('{}'), 201);
|
||||
}),
|
||||
);
|
||||
|
||||
await repository.submitSite(draft);
|
||||
|
||||
expect(captured!.method, 'POST');
|
||||
expect(captured!.url.toString(), 'http://api.test/api/sites');
|
||||
final Map<String, dynamic> body =
|
||||
jsonDecode(captured!.body) as Map<String, dynamic>;
|
||||
expect(body['title'], 'Hugging Face');
|
||||
expect(body['url'], 'https://huggingface.co');
|
||||
expect(body['group'], 'AI 工具');
|
||||
expect(body['subGroup'], '模型社区');
|
||||
expect(body['submittedBy'], '[email protected]');
|
||||
expect(body.containsKey('categoryId'), isFalse, reason: '不能自己指定分组 id');
|
||||
});
|
||||
|
||||
test('后端返回 400 时抛出后端的中文说明', () async {
|
||||
final HttpHomeRepository repository = repositoryReturning(
|
||||
'{"error":"url 不能是本机或内网地址:localhost"}',
|
||||
status: 400,
|
||||
);
|
||||
await expectLater(
|
||||
repository.submitSite(draft),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
allOf(contains('400'), contains('不能是本机或内网地址')),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('连不上后端时给出地址', () async {
|
||||
final HttpHomeRepository repository = HttpHomeRepository(
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
client: MockClient(
|
||||
(http.Request request) async => throw const SocketExceptionStub(),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
repository.submitSite(draft),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('连不上后端'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 用来模拟「连不上」的异常。
|
||||
class SocketExceptionStub implements Exception {
|
||||
const SocketExceptionStub();
|
||||
|
||||
@override
|
||||
String toString() => 'Connection refused';
|
||||
}
|
||||
@@ -7,7 +7,12 @@ import 'package:frontend/features/home/domain/models/nav_node.dart';
|
||||
|
||||
void main() {
|
||||
const MockHomeRepository repository = MockHomeRepository();
|
||||
final HomeData data = repository.fetchHomeData();
|
||||
late HomeData data;
|
||||
|
||||
// 取数改成异步之后,每个用例开始前先拿一次。
|
||||
setUp(() async {
|
||||
data = await repository.fetchHomeData();
|
||||
});
|
||||
|
||||
group('导航树(对照 index3.html 的 nav-group)', () {
|
||||
test('三个一级条目:一个直接跳转,两个可展开分组', () {
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/core/constants/app_sizes.dart';
|
||||
import 'package:frontend/core/theme/app_colors.dart';
|
||||
import 'package:frontend/features/home/data/home_repository.dart';
|
||||
import 'package:frontend/features/home/data/mock_home_data.dart';
|
||||
import 'package:frontend/features/home/data/mock_home_repository.dart';
|
||||
import 'package:frontend/features/home/domain/models/home_data.dart';
|
||||
import 'package:frontend/features/home/domain/models/link_item.dart';
|
||||
import 'package:frontend/features/home/domain/models/link_section.dart';
|
||||
import 'package:frontend/features/home/domain/models/nav_node.dart';
|
||||
import 'package:frontend/features/home/domain/models/submit_site_draft.dart';
|
||||
import 'package:frontend/features/home/presentation/pages/home_page.dart';
|
||||
import 'package:frontend/features/home/presentation/widgets/link_card.dart';
|
||||
import 'package:frontend/features/home/presentation/widgets/link_section_view.dart';
|
||||
import 'package:frontend/features/home/presentation/widgets/sidebar_nav.dart';
|
||||
import 'package:frontend/features/home/presentation/widgets/site_form_dialog.dart';
|
||||
|
||||
import '../../../helpers/fake_url_opener.dart';
|
||||
|
||||
@@ -22,6 +32,9 @@ void main() {
|
||||
// 高度取 420:原型只有两段内容,视口再高就滚不动了,
|
||||
// 「回到顶部」这类用例也就没有意义。
|
||||
Size size = const Size(900, 420),
|
||||
HomeRepository repository = const MockHomeRepository(),
|
||||
/// 加载中的用例要停在第一帧,不能等它 settle。
|
||||
bool settle = true,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -30,12 +43,17 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomePage(
|
||||
repository: const MockHomeRepository(),
|
||||
repository: repository,
|
||||
urlOpener: urlOpener,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
// 数据现在来自 Future,等它落地后再断言。
|
||||
if (settle) {
|
||||
await tester.pumpAndSettle();
|
||||
} else {
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
/// 侧栏里的导航文字(避免和内容区的分区标题同名而混淆)。
|
||||
@@ -319,4 +337,297 @@ void main() {
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('加载状态', () {
|
||||
testWidgets('数据没回来时显示加载提示', (WidgetTester tester) async {
|
||||
final _PendingRepository repository = _PendingRepository();
|
||||
await pumpPage(tester, repository: repository, settle: false);
|
||||
|
||||
expect(find.text('正在加载…'), findsOneWidget);
|
||||
expect(find.byType(LinkSectionView), findsNothing);
|
||||
|
||||
repository.completer.complete(mockHomeData);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('正在加载…'), findsNothing);
|
||||
expect(find.byType(LinkSectionView), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('取数失败时显示原因和重试按钮', (WidgetTester tester) async {
|
||||
await pumpPage(
|
||||
tester,
|
||||
repository: const _FailingRepository('连不上后端(http://127.0.0.1:8080)'),
|
||||
);
|
||||
|
||||
expect(find.text('加载失败'), findsOneWidget);
|
||||
expect(find.textContaining('连不上后端'), findsOneWidget);
|
||||
expect(find.text('重试'), findsOneWidget);
|
||||
expect(find.byType(LinkSectionView), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('点重试会重新取数,成功后渲染内容', (WidgetTester tester) async {
|
||||
final _FlakyRepository repository = _FlakyRepository();
|
||||
await pumpPage(tester, repository: repository);
|
||||
|
||||
expect(find.text('加载失败'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('重试'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.calls, 2);
|
||||
expect(find.text('加载失败'), findsNothing);
|
||||
expect(find.byType(LinkSectionView), findsNWidgets(2));
|
||||
expect(find.byType(LinkCard), findsNWidgets(5));
|
||||
});
|
||||
});
|
||||
|
||||
group('提交网站', () {
|
||||
testWidgets('点侧栏底部按钮打开表单;提交成功后刷新一次并提示', (WidgetTester tester) async {
|
||||
final _RecordingRepository repository = _RecordingRepository();
|
||||
// 表单比默认视口高,用大一点的窗口,免得字段在屏幕外点不到。
|
||||
await pumpPage(
|
||||
tester,
|
||||
size: const Size(1000, 1200),
|
||||
repository: repository,
|
||||
);
|
||||
expect(repository.fetches, 1);
|
||||
|
||||
await tester.tap(navText('提交网站'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('网站名称'), findsOneWidget);
|
||||
expect(find.text('提交的网站会放在你自己的分组里,不会进内置分类。'), findsOneWidget);
|
||||
|
||||
// 页面里除了表单还有顶部搜索框,这里限定在对话框内部找输入框。
|
||||
Finder field(int index) => find
|
||||
.descendant(
|
||||
of: find.byType(SiteFormDialog),
|
||||
matching: find.byType(TextField),
|
||||
)
|
||||
.at(index);
|
||||
|
||||
await tester.enterText(field(0), 'Hugging Face');
|
||||
await tester.enterText(field(1), 'https://huggingface.co');
|
||||
await tester.enterText(field(3), 'AI 工具');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.submitted, hasLength(1));
|
||||
expect(repository.submitted.single.group, 'AI 工具');
|
||||
expect(repository.fetches, 2, reason: '提交成功后要重新拉一次数据');
|
||||
expect(find.text('已提交,感谢'), findsOneWidget);
|
||||
expect(find.text('网站名称'), findsNothing, reason: '对话框已经关掉');
|
||||
});
|
||||
});
|
||||
|
||||
group('编辑与删除', () {
|
||||
testWidgets('可修改的条目能编辑,保存后刷新', (WidgetTester tester) async {
|
||||
final _MutableRepository repository = _MutableRepository();
|
||||
await pumpPage(
|
||||
tester,
|
||||
size: const Size(1000, 1200),
|
||||
repository: repository,
|
||||
);
|
||||
expect(repository.fetches, 1);
|
||||
|
||||
await tester.tap(find.byTooltip('编辑'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 表单预填了现有内容,提交者字段不出现。
|
||||
expect(find.text('编辑网站'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(SiteFormDialog),
|
||||
matching: find.byType(TextField),
|
||||
)
|
||||
.at(0),
|
||||
)
|
||||
.controller!
|
||||
.text,
|
||||
'Hugging Face',
|
||||
);
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.updated, <String>['s-1']);
|
||||
expect(repository.fetches, 2, reason: '保存成功后要重新拉一次数据');
|
||||
expect(find.text('已保存'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('删除会先确认,确认后才真的删并刷新', (WidgetTester tester) async {
|
||||
final _MutableRepository repository = _MutableRepository();
|
||||
await pumpPage(
|
||||
tester,
|
||||
size: const Size(1000, 1200),
|
||||
repository: repository,
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('删除'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('确定要删除「Hugging Face」'), findsOneWidget);
|
||||
|
||||
// 先点取消:什么都不该发生。
|
||||
await tester.tap(find.text('取消'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(repository.deleted, isEmpty);
|
||||
|
||||
await tester.tap(find.byTooltip('删除'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('删除'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.deleted, <String>['s-1']);
|
||||
expect(repository.fetches, 2);
|
||||
expect(find.text('已删除'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('内置条目没有编辑和删除入口', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
expect(find.byType(LinkCard), findsWidgets);
|
||||
expect(find.byTooltip('编辑'), findsNothing);
|
||||
expect(find.byTooltip('删除'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 返回一条「用户提交」的站点(editable),并记录改删调用。
|
||||
class _MutableRepository implements HomeRepository {
|
||||
int fetches = 0;
|
||||
final List<String> updated = <String>[];
|
||||
final List<String> deleted = <String>[];
|
||||
|
||||
static const HomeData _data = HomeData(
|
||||
siteName: 'Suxing Nav',
|
||||
searchHint: '输入关键字搜索全网资源...',
|
||||
navNodes: <NavNode>[NavNode(id: 'g-1', title: 'AI 工具', editable: true)],
|
||||
sections: <LinkSection>[
|
||||
LinkSection(
|
||||
id: 'g-1',
|
||||
title: 'AI 工具',
|
||||
items: <LinkItem>[
|
||||
LinkItem(
|
||||
id: 's-1',
|
||||
title: 'Hugging Face',
|
||||
url: 'https://huggingface.co',
|
||||
description: '开源模型与数据集社区',
|
||||
categoryId: 'g-1',
|
||||
editable: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() async {
|
||||
fetches++;
|
||||
return _data;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {
|
||||
updated.add(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {
|
||||
deleted.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录取数次数与提交内容,用来验证页面的联调行为。
|
||||
class _RecordingRepository implements HomeRepository {
|
||||
int fetches = 0;
|
||||
final List<SubmitSiteDraft> submitted = <SubmitSiteDraft>[];
|
||||
final List<String> updated = <String>[];
|
||||
final List<String> deleted = <String>[];
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() async {
|
||||
fetches++;
|
||||
return mockHomeData;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {
|
||||
submitted.add(draft);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {
|
||||
updated.add(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {
|
||||
deleted.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据一直不回来,用来测加载中的那一帧。
|
||||
class _PendingRepository implements HomeRepository {
|
||||
final Completer<HomeData> completer = Completer<HomeData>();
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() => completer.future;
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {}
|
||||
}
|
||||
|
||||
/// 永远失败。
|
||||
class _FailingRepository implements HomeRepository {
|
||||
const _FailingRepository(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() async => throw HomeFailure(message);
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {}
|
||||
}
|
||||
|
||||
/// 第一次失败、第二次成功,用来验证重试按钮真的重新取数。
|
||||
class _FlakyRepository implements HomeRepository {
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() async {
|
||||
calls++;
|
||||
if (calls == 1) {
|
||||
throw const HomeFailure('后端没起来');
|
||||
}
|
||||
return mockHomeData;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/features/home/data/home_repository.dart';
|
||||
import 'package:frontend/features/home/data/mock_home_data.dart';
|
||||
import 'package:frontend/features/home/domain/models/home_data.dart';
|
||||
import 'package:frontend/features/home/domain/models/nav_node.dart';
|
||||
import 'package:frontend/features/home/domain/models/submit_site_draft.dart';
|
||||
import 'package:frontend/features/home/presentation/widgets/site_form_dialog.dart';
|
||||
|
||||
/// 记录提交内容的假仓库。[failWith] 有值时模拟后端报错,[gate] 用来卡住请求。
|
||||
class _RecordingRepository implements HomeRepository {
|
||||
final List<SubmitSiteDraft> submitted = <SubmitSiteDraft>[];
|
||||
String? failWith;
|
||||
Completer<void>? gate;
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() async => mockHomeData;
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {
|
||||
if (gate != null) {
|
||||
await gate!.future;
|
||||
}
|
||||
final String? failure = failWith;
|
||||
if (failure != null) {
|
||||
throw HomeFailure(failure);
|
||||
}
|
||||
submitted.add(draft);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {}
|
||||
}
|
||||
|
||||
/// 侧栏那棵树:内置分组不可选,用户分组可选。
|
||||
const List<NavNode> _navNodes = <NavNode>[
|
||||
NavNode(id: 'common', title: '常用推荐'),
|
||||
NavNode(
|
||||
id: 'g-1',
|
||||
title: 'AI 工具',
|
||||
editable: true,
|
||||
children: <NavNode>[
|
||||
NavNode(id: 'g-2', title: '模型社区', editable: true),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
void main() {
|
||||
late _RecordingRepository repository;
|
||||
late bool? dialogResult;
|
||||
|
||||
setUp(() {
|
||||
repository = _RecordingRepository();
|
||||
dialogResult = null;
|
||||
});
|
||||
|
||||
Future<void> openDialog(
|
||||
WidgetTester tester, {
|
||||
String heading = '提交网站',
|
||||
SubmitSiteDraft? initial,
|
||||
}) async {
|
||||
// 表单比默认测试视口高,放大一点免得字段跑到屏幕外。
|
||||
tester.view.physicalSize = const Size(1000, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (BuildContext context) => TextButton(
|
||||
onPressed: () async {
|
||||
dialogResult = await showSiteFormDialog(
|
||||
context: context,
|
||||
navNodes: _navNodes,
|
||||
heading: heading,
|
||||
initial: initial,
|
||||
onSubmit: (SubmitSiteDraft draft) =>
|
||||
repository.submitSite(draft),
|
||||
);
|
||||
},
|
||||
child: const Text('打开'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('打开'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// 按顺序填字段:0 名称、1 地址、2 描述、3 分组、4 子分组、5 称呼。
|
||||
Future<void> fill(WidgetTester tester, int index, String value) async {
|
||||
await tester.enterText(find.byType(TextField).at(index), value);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> fillValidForm(WidgetTester tester) async {
|
||||
await fill(tester, 0, 'Hugging Face');
|
||||
await fill(tester, 1, 'https://huggingface.co');
|
||||
await fill(tester, 2, '开源模型与数据集社区');
|
||||
await fill(tester, 3, 'AI 工具');
|
||||
await fill(tester, 4, '模型社区');
|
||||
await fill(tester, 5, '[email protected]');
|
||||
}
|
||||
|
||||
testWidgets('空表单提交会逐项提示,且不发请求', (WidgetTester tester) async {
|
||||
await openDialog(tester);
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('请填写网站名称'), findsOneWidget);
|
||||
expect(find.text('请填写网站地址'), findsOneWidget);
|
||||
expect(find.text('请填写分组名称'), findsOneWidget);
|
||||
expect(repository.submitted, isEmpty);
|
||||
expect(dialogResult, isNull, reason: '校验没过时对话框不关');
|
||||
});
|
||||
|
||||
testWidgets('内网地址会被拦下来', (WidgetTester tester) async {
|
||||
await openDialog(tester);
|
||||
await fill(tester, 0, '内网');
|
||||
await fill(tester, 1, 'http://192.168.1.10');
|
||||
await fill(tester, 3, 'AI 工具');
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('不能填本机或内网地址'), findsOneWidget);
|
||||
expect(repository.submitted, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('合法内容提交给仓库,成功后关闭并返回 true', (WidgetTester tester) async {
|
||||
await openDialog(tester);
|
||||
await fillValidForm(tester);
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.submitted, hasLength(1));
|
||||
final SubmitSiteDraft draft = repository.submitted.single;
|
||||
expect(draft.title, 'Hugging Face');
|
||||
expect(draft.url, 'https://huggingface.co');
|
||||
expect(draft.group, 'AI 工具');
|
||||
expect(draft.subGroup, '模型社区');
|
||||
expect(draft.submittedBy, '[email protected]');
|
||||
expect(dialogResult, isTrue);
|
||||
expect(find.text('取消'), findsNothing, reason: '对话框已经关掉');
|
||||
});
|
||||
|
||||
testWidgets('后端报错时留在表单里显示原因', (WidgetTester tester) async {
|
||||
repository.failWith = 'url 不能是本机或内网地址:localhost';
|
||||
await openDialog(tester);
|
||||
await fillValidForm(tester);
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('不能是本机或内网地址'), findsOneWidget);
|
||||
expect(repository.submitted, isEmpty);
|
||||
expect(dialogResult, isNull);
|
||||
expect(find.text('取消'), findsOneWidget, reason: '还能改了再提交');
|
||||
});
|
||||
|
||||
testWidgets('提交中按钮变成「提交中…」且不可再点', (WidgetTester tester) async {
|
||||
repository.gate = Completer<void>();
|
||||
await openDialog(tester);
|
||||
await fillValidForm(tester);
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('提交中…'), findsOneWidget);
|
||||
|
||||
repository.gate!.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(dialogResult, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('只给用户分组建议,点一下填进输入框', (WidgetTester tester) async {
|
||||
await openDialog(tester);
|
||||
|
||||
// 内置分组不出现在建议里。
|
||||
expect(find.text('AI 工具'), findsOneWidget, reason: '用户分组建议');
|
||||
expect(find.text('常用推荐'), findsNothing, reason: '内置分组不可选');
|
||||
|
||||
await tester.tap(find.text('AI 工具'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final TextField groupField = tester.widget<TextField>(
|
||||
find.byType(TextField).at(3),
|
||||
);
|
||||
expect(groupField.controller!.text, 'AI 工具');
|
||||
// 分组定了之后,它下面已有的子分组才出现。
|
||||
expect(find.text('模型社区'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('编辑模式预填现有内容,且不显示提交者字段', (WidgetTester tester) async {
|
||||
await openDialog(
|
||||
tester,
|
||||
heading: '编辑网站',
|
||||
initial: const SubmitSiteDraft(
|
||||
title: 'Hugging Face',
|
||||
url: 'https://huggingface.co',
|
||||
description: '开源模型与数据集社区',
|
||||
group: 'AI 工具',
|
||||
subGroup: '模型社区',
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('编辑网站'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField).at(0)).controller!.text,
|
||||
'Hugging Face',
|
||||
);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField).at(1)).controller!.text,
|
||||
'https://huggingface.co',
|
||||
);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField).at(3)).controller!.text,
|
||||
'AI 工具',
|
||||
);
|
||||
// 后端 PATCH 不接受 submittedBy,编辑时就不显示这个字段。
|
||||
expect(find.text('你的称呼或邮箱'), findsNothing);
|
||||
// 分组已填好,它下面的子分组建议直接出来。
|
||||
// (子分组输入框里也预填了同样的文字,所以这里用 findsWidgets。)
|
||||
expect(find.text('模型社区'), findsWidgets);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user