Files
jiu/client/lib/core/router/app_router.dart
T
wangjia 7b3a0ecdeb feat(client): 标签模板编辑器 P2 · 编辑器屏+存储+打印联动(原型同提交)
设备管理「价签·编辑」进入独立编辑器屏 /devices/label-template:
- 左实时预览走真实 renderLabelPreview 渲染路径(示例数据, debounce 重渲)
- 右属性面板:多模板管理(编辑对象/打印默认分离)、显示字段手风琴、
  高级区域位置、打印参数
- 存 shop.custom_fields.label_templates + label_template_active(零后端改)
- Option A:同物理行两字段(编号+型号 / 版本+日期)共用字号·加粗·对齐,
  各自独立开关;仅品名支持自动字号;字体不做(热敏单色)
- 打印联动:LabelPreviewDialog 改 ConsumerStatefulWidget, 未显式传模板时
  自动取本店 active 模板→所有打印入口(商品详情/出入库列表/编辑抽屉)一致生效

守护:
- 默认输出逐像素零变化由 label_render_golden_test 守(改内置默认常量才触发)
- 存储读写由 label_template_storage_test 单测守(纯内存, 不触真实库)
- 编辑器挂载由 label_template_editor_smoke_test 守
- 本屏不入自动 fidelity/golden 闸(左预览 Picture.toImage 需 runAsync,
  pumpAndSettle 骨架驱动不了), 原因+替代守法记入 CONTRACT 块4

design-first:原型 label-template-editor.html 去字体选择器、加 Option A 配对
镜像, 与真实屏同提交。真机热敏打印坐标/条码可扫须用户在打印机验收。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-30 09:56:23 +08:00

246 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../screens/auth/login_screen.dart';
import '../../screens/auth/register_screen.dart';
import '../../screens/shell/app_shell.dart';
import '../../screens/stock_in/stock_in_list_screen.dart';
import '../../screens/stock_in/stock_in_form_screen.dart';
import '../../screens/stock_out/stock_out_list_screen.dart';
import '../../screens/stock_out/stock_out_form_screen.dart';
import '../../screens/inventory/inventory_list_screen.dart';
import '../../screens/inventory/inventory_check_screen.dart';
import '../../screens/partners/partners_screen.dart';
import '../../screens/finance/finance_screen.dart';
import '../../screens/products/products_screen.dart';
import '../../screens/products/product_detail_screen.dart';
import '../../screens/public/public_product_screen.dart';
import '../../screens/public/public_shop_products_screen.dart';
import '../../screens/settings/settings_screen.dart';
import '../../screens/settings/users_screen.dart';
import '../../screens/about/about_screen.dart';
import '../../screens/devices/device_management_screen.dart';
import '../../screens/devices/label_template_editor_screen.dart';
import '../../screens/me/me_screen.dart';
import '../../screens/license/license_screen.dart';
import '../auth/auth_state.dart';
Page<void> _noTransition(Widget child) => NoTransitionPage<void>(child: child);
/// ChangeNotifier that bridges Riverpod auth state → GoRouter refreshListenable.
class _RouterNotifier extends ChangeNotifier {
final Ref _ref;
_RouterNotifier(this._ref) {
_ref.listen<AuthState>(authStateProvider, (prev, next) {
debugPrint('[Router] authState changed:'
' initialized=${next.initialized}'
' isLoggedIn=${next.isLoggedIn}'
' user=${next.user?.username}');
notifyListeners();
});
}
String? redirect(BuildContext context, GoRouterState state) {
final authState = _ref.read(authStateProvider);
final isLoggedIn = authState.isLoggedIn;
final loc = state.matchedLocation;
final isPublicRoute = loc == '/login' ||
loc == '/register' ||
loc.startsWith('/product/') ||
loc.startsWith('/shop/');
final result = !authState.initialized
? null
: (!isLoggedIn && !isPublicRoute)
? '/login'
: (isLoggedIn && loc == '/login')
? '/stock-in'
: null;
debugPrint('[Router] redirect: location=$loc'
' initialized=${authState.initialized}'
' isLoggedIn=$isLoggedIn'
'${result ?? "null (no redirect)"}');
return result;
}
}
/// Separate provider so that appRouterProvider has NO dependencies and
/// is never rebuilt when auth state changes (prevents router reset to /login).
final _routerNotifierProvider = Provider<_RouterNotifier>((ref) {
final notifier = _RouterNotifier(ref);
ref.onDispose(notifier.dispose);
return notifier;
});
final appRouterProvider = Provider<GoRouter>((ref) {
// Use ref.read (not ref.watch) so appRouterProvider never rebuilds on auth change.
final notifier = ref.read(_routerNotifierProvider);
final router = GoRouter(
initialLocation: '/login',
refreshListenable: notifier,
redirect: notifier.redirect,
routes: [
// Public product scan — no auth, no shell nav bar
GoRoute(
path: '/product/:public_id',
builder: (context, state) =>
PublicProductScreen(publicId: state.pathParameters['public_id']!),
),
// Public shop product list — no auth, no shell nav bar
GoRoute(
path: '/shop/:shop_code',
builder: (context, state) => PublicShopProductsScreen(
shopCode: state.pathParameters['shop_code']!,
shopName: state.uri.queryParameters['shopName'] ?? '',
),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/register',
builder: (context, state) => const RegisterScreen(),
),
// 各栏目拆为独立分支:StatefulShellRoute.indexedStack 让每个分支的 Navigator
// 及其页面 State 常驻,跨栏目切换不再销毁上一页(半填表单/内部 tab/滚动位置保活)。
// 分支顺序必须与 AppShell._navItems 一致(navigationShell.currentIndex 据此高亮)。
StatefulShellRoute.indexedStack(
// location 透传给壳:窄屏据此判定 tab 根 / 二级屏(底部 tabbar 显隐与返回箭头)。
builder: (context, state, navigationShell) => AppShell(
navigationShell: navigationShell,
location: state.matchedLocation),
branches: [
// 0 入库管理
StatefulShellBranch(routes: [
GoRoute(
path: '/stock-in',
pageBuilder: (_, __) =>
_noTransition(const StockInListScreen())),
GoRoute(
path: '/stock-in/new',
pageBuilder: (_, __) =>
_noTransition(const StockInFormScreen())),
GoRoute(
path: '/stock-in/edit/:id',
pageBuilder: (_, state) => _noTransition(StockInFormScreen(
editOrderId: int.parse(state.pathParameters['id']!)))),
]),
// 1 出库管理
StatefulShellBranch(routes: [
GoRoute(
path: '/stock-out',
pageBuilder: (_, __) =>
_noTransition(const StockOutListScreen())),
GoRoute(
path: '/stock-out/new',
pageBuilder: (_, __) =>
_noTransition(const StockOutFormScreen())),
GoRoute(
path: '/stock-out/edit/:id',
pageBuilder: (_, state) => _noTransition(StockOutFormScreen(
editOrderId: int.parse(state.pathParameters['id']!)))),
]),
// 2 库存管理
StatefulShellBranch(routes: [
GoRoute(
path: '/inventory',
pageBuilder: (_, __) =>
_noTransition(const InventoryListScreen())),
GoRoute(
path: '/inventory/check',
pageBuilder: (_, __) =>
_noTransition(const InventoryCheckScreen())),
]),
// 3 财务管理
StatefulShellBranch(routes: [
GoRoute(
path: '/finance',
pageBuilder: (_, __) => _noTransition(const FinanceScreen())),
]),
// 4 往来单位
StatefulShellBranch(routes: [
GoRoute(
path: '/partners',
pageBuilder: (_, __) => _noTransition(const PartnersScreen())),
]),
// 5 基础数据
StatefulShellBranch(routes: [
GoRoute(
path: '/products',
// ?tab=intro → 商品介绍 tab(对齐原型 products.html?tab=intro
pageBuilder: (_, state) => _noTransition(ProductsScreen(
initialTab:
state.uri.queryParameters['tab'] == 'intro' ? 1 : 0))),
GoRoute(
path: '/products/:id',
pageBuilder: (_, state) => _noTransition(ProductDetailScreen(
productId: int.parse(state.pathParameters['id']!)))),
]),
// 6 设备管理
StatefulShellBranch(routes: [
GoRoute(
path: '/devices',
pageBuilder: (_, __) =>
_noTransition(const DeviceManagementScreen())),
// 标签价签模板编辑器(照原型 label-template-editor.html
GoRoute(
path: '/devices/label-template',
pageBuilder: (_, __) =>
_noTransition(const LabelTemplateEditorScreen())),
]),
// 7 系统设置(面板:门店信息/用户管理/编号规则/偏好设置;
// 授权管理 2026-07-10 提升为独立一级屏,见 branch 10 `/license`
StatefulShellBranch(routes: [
GoRoute(
path: '/settings',
pageBuilder: (_, state) {
const tabIndex = {
'shop': 0,
'users': 1,
'number': 2,
'pref': 3,
};
final tab = tabIndex[state.uri.queryParameters['tab']] ?? 0;
return _noTransition(SettingsScreen(initialTab: tab));
}),
// 用户管理独立页(照原型 users.html
GoRoute(
path: '/settings/users',
pageBuilder: (_, __) => _noTransition(const UsersScreen())),
]),
// 8 关于我们
StatefulShellBranch(routes: [
GoRoute(
path: '/about',
pageBuilder: (_, __) => _noTransition(const AboutScreen())),
]),
// 9 我的(移动 hub,原型 m-me.html;桌面侧栏无此项,仅窄屏底部 tab 入口)
StatefulShellBranch(routes: [
GoRoute(
path: '/me',
pageBuilder: (_, __) => _noTransition(const MeScreen())),
// 授权管理独立屏(移动形态,原型 m-license.html,从「我的」系统组进入);
// 同一 LicenseScreen widget,内部按 context.isMobile 切两种布局。
GoRoute(
path: '/me/license',
pageBuilder: (_, __) => _noTransition(const LicenseScreen())),
]),
// 10 授权管理(桌面独立一级屏,原型 license.html,侧边栏「系统」组);
// 追加在末尾以不打乱既有分支序号(0-9 全部不变)。
StatefulShellBranch(routes: [
GoRoute(
path: '/license',
pageBuilder: (_, __) => _noTransition(const LicenseScreen())),
]),
],
),
],
);
ref.onDispose(router.dispose);
return router;
});