app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
+19
View File
@@ -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];
+52
View File
@@ -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()));
}
+51
View File
@@ -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 会直接 throw11)。
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();
}
}
+24
View File
@@ -0,0 +1,24 @@
name: core_router
description: 路由聚合层。GoRouter 实例、登录态 redirect、菜单 code → 路由映射,并 re-export go_router 类型。
publish_to: none
version: 0.1.0
resolution: workspace
# core_router → core_auth 是 01 明确允许的三条 core 间依赖之一(redirect 需要登录态)。
# 注意:本包**不**依赖任何 feature_*。feature 路由由 app/ 通过 appRoutesProvider 注入,
# 详见 lib/src/app_router.dart 顶部注释与 SCAFFOLD-NOTES.md §G。
environment:
sdk: ^3.12.0
dependencies:
core_auth: ^0.1.0
core_foundation: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
go_router: ^17.5.0
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
@@ -0,0 +1,102 @@
// core_router 的高价值断言:
// 1. 未选店时**绝不能**放行到业务页——那边 currentStoreIdProvider 会直接 throw。
// 2. 会话未就绪时停在启动页,不能提前放行。
// 3. H5 路由里只有 target,永远不出现裸 URL(安全)。
import 'package:core_auth/core_auth.dart';
import 'package:core_router/core_router.dart';
import 'package:flutter_test/flutter_test.dart';
const UserContext _user = UserContext(
userId: 'U1',
employeeId: 'E1',
phone: '13800000000',
roleCode: 'CLERK',
channel: 'APP',
);
const StoreContext _store = StoreContext(
storeId: 7,
storeCode: 'S007',
storeName: '测试门店',
orgId: 1,
);
void main() {
group('resolveRedirect', () {
test('会话未就绪时停在启动页,不提前放行到业务页', () {
expect(resolveRedirect(null, '/purchase/orders', Uri.parse('/purchase/orders')), '/splash');
expect(resolveRedirect(null, '/splash', Uri.parse('/splash')), isNull);
expect(resolveRedirect(const SessionLoading(), '/home', Uri.parse('/home')), '/splash');
});
test('未登录访问业务页 → 登录页并带上回跳目标', () {
final String? to = resolveRedirect(
const SessionUnauthenticated(),
'/purchase/orders',
Uri.parse('/purchase/orders?id=9'),
);
expect(to, startsWith('/login?from='));
expect(Uri.decodeComponent(Uri.parse(to!).queryParameters['from']!), '/purchase/orders?id=9');
});
test('未登录停在登录页时不再跳转(否则死循环)', () {
expect(
resolveRedirect(const SessionUnauthenticated(), '/login', Uri.parse('/login')),
isNull,
);
});
test('已登录但未选店时,任何业务页都被挡回选店页', () {
const AppSession awaiting = SessionAwaitingStore(user: _user, candidates: <StoreContext>[]);
// 这条是核心:放行过去 currentStoreIdProvider 会 throw11)。
expect(resolveRedirect(awaiting, '/home', Uri.parse('/home')), '/store-picker');
expect(resolveRedirect(awaiting, '/store-picker', Uri.parse('/store-picker')), isNull);
});
group('已登录且已选店', () {
const AppSession active = SessionActive(user: _user, store: _store);
test('停在启动页/选店页时进工作台', () {
expect(resolveRedirect(active, '/splash', Uri.parse('/splash')), '/home');
expect(resolveRedirect(active, '/store-picker', Uri.parse('/store-picker')), '/home');
});
test('登录页带 from 时回跳原目标,没有 from 时进工作台', () {
expect(
resolveRedirect(
active,
'/login',
Uri.parse('/login?from=${Uri.encodeComponent('/purchase/orders?id=9')}'),
),
'/purchase/orders?id=9',
);
expect(resolveRedirect(active, '/login', Uri.parse('/login')), '/home');
});
test('业务页放行', () {
expect(resolveRedirect(active, '/purchase/orders', Uri.parse('/purchase/orders')), isNull);
});
});
});
group('菜单与 H5 路由', () {
test('未知菜单 code 返回 null,由调用方隐藏并上报', () {
expect(resolveMenuRoute('PURCHASE_ORDER'), '/purchase/orders');
expect(resolveMenuRoute('SOMETHING_NEW_FROM_BACKEND'), isNull);
});
test('H5 路由只带 target,不出现裸 URL', () {
final String route = AppRoutes.webviewFor('QUOTE_ORDER', title: '报价开单');
expect(route, startsWith('/webview?target=QUOTE_ORDER'));
expect(route, isNot(contains('http')));
expect(Uri.parse(route).queryParameters['title'], '报价开单');
});
test('菜单表里的 H5 项也是 target 形态', () {
for (final String path in menuRouteMap.values) {
expect(path, isNot(contains('http')), reason: '路由里塞裸 URL 是明确的安全洞');
}
});
});
}