app scaffold
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
/// 路由聚合层。来源:conti-docs/04-routing.md。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// **本包 re-export `go_router`**,`feature_*` 一律 `import 'package:core_router/core_router.dart'`
|
||||
/// 拿 `GoRoute` / `context.go` 等类型,pubspec 里**不写 go_router**。
|
||||
///
|
||||
/// 这样将来换路由库(或 go_router 出 breaking change)时,改动收敛在这一个包
|
||||
/// 里,而不是 20 个 feature 的 import 语句。
|
||||
/// ---------------------------------------------------------------------------
|
||||
library;
|
||||
|
||||
export 'package:go_router/go_router.dart';
|
||||
|
||||
export 'src/app_router.dart';
|
||||
export 'src/menu_route_map.dart';
|
||||
export 'src/pages.dart';
|
||||
export 'src/ports.dart';
|
||||
export 'src/redirect.dart';
|
||||
export 'src/route_paths.dart';
|
||||
@@ -0,0 +1,90 @@
|
||||
/// GoRouter 实例。来源:conti-docs/04-routing.md。
|
||||
library;
|
||||
|
||||
import 'package:core_auth/core_auth.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'pages.dart';
|
||||
import 'ports.dart';
|
||||
import 'redirect.dart';
|
||||
import 'route_paths.dart';
|
||||
|
||||
/// 全局 Navigator key。顶层 dialog / 无 context 跳转需要它。
|
||||
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
/// 全 App 唯一的 [GoRouter]。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// **`GoRouter` 实例不能因为登录态变化被重建。** 重建会丢掉整个导航栈——用户
|
||||
/// 在三级页面上 token 刷新了一下,就被弹回首页。
|
||||
///
|
||||
/// 所以:
|
||||
/// - `redirect` 里**只能 `ref.read`**,不能 `ref.watch`(watch 会让这个
|
||||
/// Provider 本身重建)。
|
||||
/// - 订阅由外面的 `ref.listen` 负责,变化时调 `router.refresh()` 只重跑一次
|
||||
/// `redirect`,导航栈保留。
|
||||
/// - 用 `ref.listen` 而不是 `refreshListenable`:登录态本身是 Riverpod
|
||||
/// provider,用 `refreshListenable` 还要额外包一层 `ChangeNotifier`。
|
||||
/// - `ref.onDispose(router.dispose)` 不能漏:`GoRouter` 持有 `Listenable`,
|
||||
/// 不 dispose 在热重载和测试里会泄漏。
|
||||
///
|
||||
/// 路由表本身**不在这里写死**:feature 路由由 `appRoutesProvider` 注入,
|
||||
/// 否则 core_router 就得依赖每一个 feature_*,违反 01 的分层。
|
||||
/// ---------------------------------------------------------------------------
|
||||
final Provider<GoRouter> goRouterProvider = Provider<GoRouter>((Ref ref) {
|
||||
final GoRouter router = GoRouter(
|
||||
navigatorKey: rootNavigatorKey,
|
||||
initialLocation: AppRoutes.splash,
|
||||
observers: ref.read(navigatorObserversProvider),
|
||||
redirect: (BuildContext context, GoRouterState state) =>
|
||||
_redirect(ref, state.matchedLocation, state.uri),
|
||||
errorBuilder: (BuildContext context, GoRouterState state) {
|
||||
// 上报:能直接暴露出后端下发了 App 不支持的菜单。
|
||||
ref.read(routeReporterProvider).onRouteNotFound(state.uri.toString());
|
||||
return RouteNotFoundPage(location: state.uri.toString());
|
||||
},
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: AppRoutes.splash,
|
||||
builder: (BuildContext context, GoRouterState state) => const SplashPage(),
|
||||
),
|
||||
...ref.read(appRoutesProvider),
|
||||
],
|
||||
);
|
||||
|
||||
AppSession? previous = ref.read(sessionProvider).value;
|
||||
ref.listen<AsyncValue<AppSession>>(sessionProvider, (
|
||||
AsyncValue<AppSession>? _,
|
||||
AsyncValue<AppSession> next,
|
||||
) {
|
||||
final AppSession? before = previous;
|
||||
final AppSession? after = next.value;
|
||||
previous = after;
|
||||
|
||||
// 11 §切店级联的第 6 步:切店成功后清空导航栈回工作台。
|
||||
//
|
||||
// 这一步文档写在 SessionNotifier.switchStore 里,但 core_auth 不能依赖
|
||||
// core_router(不是 01 允许的那三条边),所以反过来由这里监听落地。
|
||||
// 理由见 04 §门店切换后的路由重置:用户在 A 门店的
|
||||
// /purchase/orders/123 切到 B 门店,这个订单在 B 门店可能不存在,
|
||||
// 或者更糟——存在但是另一张单。
|
||||
if (before is SessionActive &&
|
||||
after is SessionActive &&
|
||||
before.store.storeId != after.store.storeId) {
|
||||
// go 而不是 push:替换整个栈。
|
||||
router.go(AppRoutes.home);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
ref.onDispose(router.dispose);
|
||||
return router;
|
||||
});
|
||||
|
||||
String? _redirect(Ref ref, String matchedLocation, Uri uri) {
|
||||
// read 不是 watch:这里只要当前值,订阅由上面的 listen 负责。
|
||||
return resolveRedirect(ref.read(sessionProvider).value, matchedLocation, uri);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// 后端动态菜单 code → 本地路由的映射。来源:conti-docs/04-routing.md。
|
||||
library;
|
||||
|
||||
import 'route_paths.dart';
|
||||
|
||||
/// 菜单 code → 路由路径。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// PRD §22.2:工作台菜单由后端按角色权限下发,不是写死在 App 里的。但
|
||||
/// **路由表必须是编译期写死的**(页面是 Dart 代码,不可能动态下发),所以中间
|
||||
/// 需要这张表。
|
||||
///
|
||||
/// **`code` 一旦定义就不能改含义**——改了等于老版本 App 跳错页面。新增功能
|
||||
/// 只能加新 code。这条要在后端接口评审时对齐。
|
||||
///
|
||||
/// TODO(backend): 目前只有 04 举例的三条,完整菜单 code 表待后端下发后补齐。
|
||||
/// ---------------------------------------------------------------------------
|
||||
const Map<String, String> menuRouteMap = <String, String>{
|
||||
'PURCHASE_ORDER': '/purchase/orders',
|
||||
'INVENTORY_CHECK': '/inventory/check',
|
||||
// H5 承载的功能也走这张表,形态是 /webview?target=<CODE>,不是裸 URL。
|
||||
'QUOTE_ORDER': '${AppRoutes.webview}?target=QUOTE_ORDER',
|
||||
};
|
||||
|
||||
/// 解析菜单 code。未知 code 返回 null。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// **未知 code 的处理:隐藏该菜单项 + 上报 `menu_code_unsupported`(带 code 和
|
||||
/// App 版本)。调用方负责这两件事**——本函数是纯的,没有上报通道。
|
||||
///
|
||||
/// 不选"提示升级":老版本 App 上会因为后端加了新菜单就弹升级提示,对完全
|
||||
/// 不需要这个新功能的门店是骚扰。隐藏 + 上报能让我们从数据上看到"有多少用户
|
||||
/// 因为版本旧看不到新功能",需要推升级时再针对性推。
|
||||
/// ---------------------------------------------------------------------------
|
||||
String? resolveMenuRoute(String code) => menuRouteMap[code];
|
||||
@@ -0,0 +1,52 @@
|
||||
/// 路由兜底页与启动页。来源:conti-docs/04-routing.md §errorBuilder 是必须的。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 未注册路径的兜底页。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 不写 `errorBuilder`,go_router 会显示一个英文的默认错误页——对门店一线员工
|
||||
/// 来说等于崩溃。
|
||||
///
|
||||
/// 触发场景:深链接拼错、后端下发了 App 还不认识的菜单 code、H5 回跳的 URL
|
||||
/// 有问题。所以文案指向"升级 App"而不是"稍后重试"。
|
||||
/// ---------------------------------------------------------------------------
|
||||
class RouteNotFoundPage extends StatelessWidget {
|
||||
/// [location] 只用于开发期排查,不展示给用户。
|
||||
const RouteNotFoundPage({required this.location, super.key});
|
||||
|
||||
/// 出问题的路径。
|
||||
final String location;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('页面不存在')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text('页面不存在', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text('请检查是否需要升级 App', style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 会话恢复期间的占位页。
|
||||
///
|
||||
/// 冷启动要先读 token、拉用户、拉门店(11),这段时间还不知道该去登录页还是
|
||||
/// 工作台。停在这里,由 `redirect` 在会话就绪后把用户送走。
|
||||
class SplashPage extends StatelessWidget {
|
||||
/// 构造。
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/// core_router 向外部索取的东西。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 依赖反转(同 core_auth/src/session_ports.dart、core_network/src/ports.dart)。
|
||||
///
|
||||
/// 01 规定 `core_router` 只能依赖 `core_auth`。但它需要两样东西是别处的:
|
||||
/// - **各 feature 的路由**(在 `feature_*` 里,core_* 不能依赖 feature_*)
|
||||
/// - **上报通道**(在 core_logging / core_analytics 里,不是允许的依赖边)
|
||||
///
|
||||
/// 所以在这里声明接口和 provider,由 `app/bootstrap.dart` 统一 override。
|
||||
/// ---------------------------------------------------------------------------
|
||||
library;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 全 App 的路由表。
|
||||
///
|
||||
/// core_router 自己只提供 `/splash`;`/login`、`/home` 等由各 feature 的
|
||||
/// `buildXxxRoutes()` 产出,在 `app/` 里拼好后 override 进来。
|
||||
///
|
||||
/// **默认是空表**——不 override 的话除了启动页什么都打不开,会立刻在开发期
|
||||
/// 暴露出来,比默默跑起来一个没有页面的 App 好。
|
||||
final Provider<List<RouteBase>> appRoutesProvider = Provider<List<RouteBase>>(
|
||||
(Ref ref) => const <RouteBase>[],
|
||||
);
|
||||
|
||||
/// 导航监听器。`app/` 里注入面包屑上报的那个(13)。
|
||||
final Provider<List<NavigatorObserver>> navigatorObserversProvider =
|
||||
Provider<List<NavigatorObserver>>((Ref ref) => const <NavigatorObserver>[]);
|
||||
|
||||
/// 路由异常上报。
|
||||
abstract interface class RouteReporter {
|
||||
/// 命中 `errorBuilder`(未注册的路径)。
|
||||
///
|
||||
/// 这个上报很有价值:能直接暴露出后端下发了 App 不支持的菜单。
|
||||
void onRouteNotFound(String location);
|
||||
}
|
||||
|
||||
class _NoopRouteReporter implements RouteReporter {
|
||||
const _NoopRouteReporter();
|
||||
|
||||
@override
|
||||
void onRouteNotFound(String location) {}
|
||||
}
|
||||
|
||||
/// 由 `app/` override 成真实实现。
|
||||
final Provider<RouteReporter> routeReporterProvider = Provider<RouteReporter>(
|
||||
(Ref ref) => const _NoopRouteReporter(),
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
/// 登录态 → 目标路径的纯函数。来源:conti-docs/04-routing.md。
|
||||
///
|
||||
/// 单独拎出来是为了能不启 [GoRouter]、不启 `ProviderContainer` 就测——
|
||||
/// 这段分支是全 App 唯一决定"用户能不能进业务页"的地方,值得有直接的断言。
|
||||
library;
|
||||
|
||||
import 'package:core_auth/core_auth.dart';
|
||||
|
||||
import 'route_paths.dart';
|
||||
|
||||
/// 返回需要强制跳转的路径;返回 null 表示放行当前路径。
|
||||
///
|
||||
/// [session] 为 null 表示 `sessionProvider` 还没产出第一个值(冷启动瞬间)。
|
||||
String? resolveRedirect(AppSession? session, String matchedLocation, Uri uri) {
|
||||
final bool atSplash = matchedLocation == AppRoutes.splash;
|
||||
final bool atLogin = matchedLocation == AppRoutes.login;
|
||||
final bool atStorePicker = matchedLocation == AppRoutes.storePicker;
|
||||
|
||||
if (session == null) {
|
||||
return atSplash ? null : AppRoutes.splash;
|
||||
}
|
||||
|
||||
switch (session) {
|
||||
// 会话还没恢复完(冷启动读 token → 拉用户 → 拉门店):停在启动页。
|
||||
// 这时候放行到任何页面都是错的——业务页面会立刻用一个还不存在的门店 ID
|
||||
// 去发请求。
|
||||
case SessionLoading():
|
||||
return atSplash ? null : AppRoutes.splash;
|
||||
|
||||
case SessionUnauthenticated():
|
||||
if (atLogin) {
|
||||
return null;
|
||||
}
|
||||
// 带上原目标,登录成功后回跳。启动页不值得回跳。
|
||||
return atSplash
|
||||
? AppRoutes.login
|
||||
: '${AppRoutes.login}?from=${Uri.encodeComponent(uri.toString())}';
|
||||
|
||||
// 已登录但还没选店。**不能放行到业务页**:没有门店上下文,
|
||||
// currentStoreIdProvider 会直接 throw(11)。
|
||||
case SessionAwaitingStore():
|
||||
return atStorePicker ? null : AppRoutes.storePicker;
|
||||
|
||||
case SessionActive():
|
||||
if (atSplash || atStorePicker) {
|
||||
return AppRoutes.home;
|
||||
}
|
||||
if (atLogin) {
|
||||
final String? from = uri.queryParameters['from'];
|
||||
return (from == null || from.isEmpty) ? AppRoutes.home : Uri.decodeComponent(from);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// 全 App 的路由路径常量。来源:conti-docs/04-routing.md。
|
||||
library;
|
||||
|
||||
/// 路由路径。
|
||||
///
|
||||
/// 集中定义的理由:`redirect` 在 core_router、页面在各 feature,两边都要引用
|
||||
/// 同一批字符串。散着写字面量,改一个路径就会出现"跳转过去是 404"的活见鬼。
|
||||
abstract final class AppRoutes {
|
||||
/// 启动页。会话恢复(读 token → 拉用户 → 拉门店)期间停在这里。
|
||||
static const String splash = '/splash';
|
||||
|
||||
/// 登录页。由 feature_auth 提供页面。
|
||||
static const String login = '/login';
|
||||
|
||||
/// 选店页。由 feature_auth 提供页面。
|
||||
static const String storePicker = '/store-picker';
|
||||
|
||||
/// 工作台。由 feature_home 提供页面。
|
||||
static const String home = '/home';
|
||||
|
||||
/// H5 容器。
|
||||
static const String webview = '/webview';
|
||||
|
||||
/// 拼一个 H5 路由。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// **只传 target,不传裸 URL**(04 §H5 页面的路由约定)。真实 URL 由
|
||||
/// core_webview 拿 `target` 去后端换票得到。
|
||||
///
|
||||
/// 如果路由里能直接塞 URL,任何能构造深链接的地方(推送、H5 内跳转、剪贴板)
|
||||
/// 都能让 App 打开任意网页——这是一个明确的安全洞。`target` 是白名单枚举,
|
||||
/// 能打开哪些页面由后端和 App 共同决定。
|
||||
/// ---------------------------------------------------------------------------
|
||||
static String webviewFor(String target, {String? title}) {
|
||||
final StringBuffer sb = StringBuffer('$webview?target=${Uri.encodeQueryComponent(target)}');
|
||||
if (title != null && title.isNotEmpty) {
|
||||
sb.write('&title=${Uri.encodeQueryComponent(title)}');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user