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,9 @@
/// 工作台(首页)。
///
/// 对外只暴露 [buildHomeRoutes](给 app/ 拼路由表)和 [homeRepositoryProvider]
/// (给测试/后续替换实现用)。页面本身不导出。
library;
export 'src/data/home_models.dart';
export 'src/data/home_repository.dart' show HomeRepository, homeRepositoryProvider;
export 'src/routes.dart';
@@ -0,0 +1,36 @@
/// 工作台的数据模型。
///
/// 字段按 PRD 的工作台描述反推,**待与后端接口对齐**——这里只保证结构和
/// 降级逻辑成立,字段名后面照着真接口改即可。
library;
import 'package:flutter/foundation.dart';
/// 待办条目。
@immutable
class TodoItem {
/// 构造。
const TodoItem({required this.code, required this.title, required this.count});
/// 业务编码,和菜单 `code` 同一套字典——工作台的角标靠它对上菜单。
final String code;
/// 展示名。
final String title;
/// 待处理数量。
final int count;
}
/// 预警条目。
@immutable
class AlertItem {
/// 构造。
const AlertItem({required this.title, required this.detail});
/// 标题。
final String title;
/// 描述。
final String detail;
}
@@ -0,0 +1,73 @@
/// 工作台的服务端调用。
///
/// 02 §Repository 接口的位置规则:本 feature 没有 `domain` 层,接口直接声明在
/// `data/repository/`presentation 只依赖接口。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:core_network/core_network.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'home_models.dart';
/// 工作台数据。
///
/// ---------------------------------------------------------------------------
/// **两个方法,两个请求,不合并。** 合并成一个 `fetchHome()` 会把降级粒度也
/// 一起合并掉:待办挂了就连预警一起看不见(12 §四)。接口层面就分开,
/// presentation 才有分开降级的可能。
/// ---------------------------------------------------------------------------
abstract interface class HomeRepository {
/// 待办列表。
Future<List<TodoItem>> fetchTodos();
/// 预警列表。
Future<List<AlertItem>> fetchAlerts();
}
/// [HomeRepository] 的实现。
///
/// TODO(backend): 路径和字段名按 PRD 工作台描述推的,待接口契约确认后校准。
class HomeRepositoryImpl implements HomeRepository {
/// repository 一律注入 [ApiClient],不注入 Dio05)。
const HomeRepositoryImpl(this._api);
final ApiClient _api;
@override
Future<List<TodoItem>> fetchTodos() async {
final List<dynamic> raw = await _api.get<List<dynamic>>('/api/v1/home/todos');
return raw.map((dynamic e) {
final Map<String, dynamic> m = e as Map<String, dynamic>;
return TodoItem(
code: _requireString(m, 'code'),
title: _requireString(m, 'title'),
count: (m['count'] as num?)?.toInt() ?? 0,
);
}).toList();
}
@override
Future<List<AlertItem>> fetchAlerts() async {
final List<dynamic> raw = await _api.get<List<dynamic>>('/api/v1/home/alerts');
return raw.map((dynamic e) {
final Map<String, dynamic> m = e as Map<String, dynamic>;
return AlertItem(title: _requireString(m, 'title'), detail: _requireString(m, 'detail'));
}).toList();
}
// 缺字段直接当服务异常,不给默认值——理由同 feature_auth。
// count 是例外:角标缺失降级为不显示,不值得整个待办区块挂掉。
static String _requireString(Map<String, dynamic> json, String key) {
final Object? v = json[key];
if (v is! String) {
throw ServerException('响应缺少字段 $key');
}
return v;
}
}
/// presentation 通过它拿接口类型。
final Provider<HomeRepository> homeRepositoryProvider = Provider<HomeRepository>(
(Ref ref) => HomeRepositoryImpl(ref.watch(apiClientProvider)),
);
@@ -0,0 +1,235 @@
/// 工作台(首页)。降级粒度来源:conti-docs/12-error-and-api-contract.md §四。
library;
import 'dart:async';
import 'package:core_auth/core_auth.dart';
import 'package:core_router/core_router.dart';
import 'package:core_ui/core_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/home_models.dart';
import 'home_providers.dart';
/// 工作台。
///
/// ---------------------------------------------------------------------------
/// 三档降级在这个文件里的对应关系(12 §四):
/// 1. 门店上下文、菜单挂了 → [HomePage] 整页 [ErrorView] + 重试
/// 2. 待办、预警挂了 → 各自的 [_SectionAsync] 显示 [TileErrorView],其余照常
/// 3. 菜单 tile 的角标挂了 → [_MenuSection] 里不显示角标,**不显示任何错误 UI**
///
/// 每一档都由"谁 watch 谁"决定,不是由 try-catch 决定——所以这三个 section
/// 必须各 watch 各的 provider,不能在上层合并(见 `home_providers.dart`)。
/// ---------------------------------------------------------------------------
class HomePage extends ConsumerWidget {
/// 构造。
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<AppSession> session = ref.watch(sessionProvider);
final AppSession? value = session.value;
final StoreContext? store = value is SessionActive ? value.store : null;
return Scaffold(
appBar: AppBar(
title: Text(store?.storeName ?? '工作台'),
actions: <Widget>[
IconButton(
key: const Key('home_store_switch'),
icon: const Icon(Icons.store_outlined),
tooltip: '切换门店',
onPressed: () => context.push(AppRoutes.storePicker),
),
],
),
body: store == null
// 第 1 档:没有门店上下文,首页整体没有意义。
? AsyncValueView<AppSession>(
value: session,
onRetry: () => unawaited(ref.read(sessionProvider.notifier).retryStoreLoad()),
data: (AppSession _) => const Center(child: CircularProgressIndicator()),
)
: ListView(
padding: const EdgeInsets.all(16),
children: const <Widget>[_MenuSection(), _TodoSection(), _AlertSection()],
),
);
}
}
class _MenuSection extends ConsumerWidget {
const _MenuSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<MenuEntry> entries = ref.watch(homeMenuEntriesProvider);
// 第 3 档降级:角标是锦上添花,拿不到就不显示。
// 这里**故意**只取 .value 而不处理 error——待办接口挂了,菜单入口照常
// 能点,用户仍然能进去干活。给角标加一个错误 UI 只会挡住入口。
final Map<String, int> badges = <String, int>{
for (final TodoItem todo in ref.watch(homeTodosProvider).value ?? const <TodoItem>[])
todo.code: todo.count,
};
if (entries.isEmpty) {
return const _Section(title: '常用功能', child: Text('当前门店没有可用功能'));
}
return _Section(
title: '常用功能',
child: GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: <Widget>[
for (final MenuEntry entry in entries) _MenuTile(entry: entry, badge: badges[entry.code]),
],
),
);
}
}
class _MenuTile extends StatelessWidget {
const _MenuTile({required this.entry, this.badge});
final MenuEntry entry;
final int? badge;
@override
Widget build(BuildContext context) {
return InkWell(
key: Key('home_menu_${entry.code}'),
// 路由来自 menuRouteMap,绝不会是后端下发的 URL(04 的安全约定)。
onTap: () => context.push(entry.route),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Badge(
isLabelVisible: badge != null && badge! > 0,
label: Text('$badge'),
child: const Icon(Icons.widgets_outlined, size: 32),
),
const SizedBox(height: 8),
Text(entry.name, textAlign: TextAlign.center),
],
),
);
}
}
class _TodoSection extends ConsumerWidget {
const _TodoSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _Section(
title: '待办',
child: _SectionAsync<List<TodoItem>>(
value: ref.watch(homeTodosProvider),
onRetry: () => ref.invalidate(homeTodosProvider),
empty: '暂无待办',
data: (List<TodoItem> todos) => Column(
children: <Widget>[
for (final TodoItem todo in todos)
ListTile(
key: Key('home_todo_${todo.code}'),
title: Text(todo.title),
trailing: Text('${todo.count}'),
onTap: () {
final String? route = resolveMenuRoute(todo.code);
if (route != null) {
unawaited(context.push(route));
}
},
),
],
),
),
);
}
}
class _AlertSection extends ConsumerWidget {
const _AlertSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _Section(
title: '预警',
child: _SectionAsync<List<AlertItem>>(
value: ref.watch(homeAlertsProvider),
onRetry: () => ref.invalidate(homeAlertsProvider),
empty: '暂无预警',
data: (List<AlertItem> alerts) => Column(
children: <Widget>[
for (final AlertItem alert in alerts)
ListTile(title: Text(alert.title), subtitle: Text(alert.detail)),
],
),
),
);
}
}
/// 区块级三态。
///
/// 和 core_ui 的 [AsyncValueView] 唯一的区别:错误态用 [TileErrorView] 而不是
/// 整页 [ErrorView]——这就是第 2 档降级。判定顺序保持一致(先看有没有数据,
/// 刷新失败时继续渲染旧数据)。
class _SectionAsync<T> extends StatelessWidget {
const _SectionAsync({
required this.value,
required this.data,
required this.onRetry,
required this.empty,
});
final AsyncValue<T> value;
final Widget Function(T data) data;
final VoidCallback onRetry;
final String empty;
@override
Widget build(BuildContext context) {
if (value.hasValue) {
final T current = value.value as T;
if (current is List<Object?> && current.isEmpty) {
return Text(empty);
}
return data(current);
}
if (value.hasError && !ErrorPresenter.isSilent(value.error!)) {
return TileErrorView(error: value.error!, onRetry: onRetry);
}
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
}
class _Section extends StatelessWidget {
const _Section({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
child,
],
),
);
}
}
@@ -0,0 +1,84 @@
/// 工作台的 provider。来源:conti-docs/12-error-and-api-contract.md §四。
library;
import 'package:core_analytics/core_analytics.dart';
import 'package:core_auth/core_auth.dart';
import 'package:core_router/core_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/home_models.dart';
import '../data/home_repository.dart';
part 'home_providers.g.dart';
/// 一个已经解析到本端路由的菜单入口。
typedef MenuEntry = ({String code, String name, String route});
/// 待办。
///
/// ---------------------------------------------------------------------------
/// **为什么每个区块一个 provider,而不是一个 `Future.wait` 拉全部?**
///
/// `Future.wait` 的语义是"全部成功才算成功"——四个请求里任意一个挂了,整个
/// 工作台就是错误态。但门店现场最常见的情况恰恰是某一个下游服务抖动,
/// 这时把已经拿到的待办、菜单一起藏起来,用户就什么都干不了了。
///
/// 12 §四把降级粒度定死成三档:
/// 1. 门店上下文、菜单 → 整页错误态 + 重试(没有它首页无意义)
/// 2. 待办、预警、公告、促销位 → 该区块局部错误态,其余正常
/// 3. tile 上的数字/角标 → 降级为不显示角标,不显示错误 UI
///
/// 只有"一个区块一个 provider、各自 watch 各自的"这种写法能表达第 2 档。
/// ---------------------------------------------------------------------------
///
/// `ref.watch(currentStoreIdProvider)` 不是为了用返回值,而是为了**订阅门店**:
/// 切店后本 provider 自动失效重拉(11 §切店级联的第 4 步)。漏了这一句,
/// 切完店首页还显示上一家店的待办。
@riverpod
Future<List<TodoItem>> homeTodos(Ref ref) {
ref.watch(currentStoreIdProvider);
return ref.watch(homeRepositoryProvider).fetchTodos();
}
/// 预警。失败时只影响预警区块,见 [homeTodos] 的说明。
@riverpod
Future<List<AlertItem>> homeAlerts(Ref ref) {
ref.watch(currentStoreIdProvider);
return ref.watch(homeRepositoryProvider).fetchAlerts();
}
/// 当前门店的菜单,已按本端路由表过滤。
///
/// ---------------------------------------------------------------------------
/// 后端下发了本端没有的编码时:**隐藏该入口 + 上报**(04)。不能弹错、不能
/// 留一个点了没反应的格子——灰度期后端先配菜单、App 后发版是常态。
///
/// 解析和上报放在 provider 里而不是 `build()` 里:`build()` 每帧都可能重跑,
/// 埋点会被刷爆;provider 只在门店(菜单随门店下发)变化时重算一次。
/// ---------------------------------------------------------------------------
@riverpod
List<MenuEntry> homeMenuEntries(Ref ref) {
final AppSession? session = ref.watch(sessionProvider).value;
final List<MenuItem> menus = session is SessionActive ? session.store.menus : const <MenuItem>[];
final List<MenuEntry> entries = <MenuEntry>[];
final List<String> unsupported = <String>[];
for (final MenuItem item in menus) {
final String? route = resolveMenuRoute(item.code);
if (route == null) {
unsupported.add(item.code);
continue;
}
entries.add((code: item.code, name: item.name, route: route));
}
if (unsupported.isNotEmpty) {
final Analytics analytics = ref.read(analyticsProvider);
for (final String code in unsupported) {
analytics.track(AnalyticsEvent.menuCodeUnsupported, <String, Object?>{
AnalyticsParam.code: code,
});
}
}
return entries;
}
+19
View File
@@ -0,0 +1,19 @@
/// 本 feature 对外暴露的路由。来源:conti-docs/04-routing.md。
library;
import 'package:core_router/core_router.dart';
import 'package:flutter/widgets.dart';
import 'presentation/home_page.dart';
/// 工作台路由。
///
/// 拼装在 `app/` 的 `appRoutesProvider` 里完成——core_router 不依赖任何
/// featurefeature 之间也不互相 import。`GoRoute` 来自 core_router 的
/// re-export,本包 pubspec 里没有 go_router。
List<RouteBase> buildHomeRoutes() => <RouteBase>[
GoRoute(
path: AppRoutes.home,
builder: (BuildContext context, GoRouterState state) => const HomePage(),
),
];