Files
nj/app/lib/widgets/responsive_scaffold.dart
iven b320641d9c
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 全链路验证修复 — 编译错误/CORS/迁移/启动脚本
前端修复:
- calendar_page: 移除不存在的 JournalEntry.content getter
- responsive_scaffold: 移除不存在的 notchThickness 参数
- splash_page: SingleTickerProvider → TickerProvider (多 AnimationController)
- profile_page: UserRoleType.name → .code (修复运行时崩溃)
- 导入缺失的 user.dart

后端修复:
- class_service: generate_class_code 取 UUID 后6位(随机部分)避免碰撞
- diary_role_seed: 移除不存在的 id 列,使用复合主键 ON CONFLICT

基础设施:
- config/default.toml: CORS 改为通配符(开发模式)
- scripts/dev.sh: 统一启动脚本(自动清理端口)
- docs/opendesign/: Open Design 设计规格 HTML 原型稿

验证结果: flutter analyze 0 error, cargo test 77/77 通过, 17个页面全部渲染正常
2026-06-02 01:03:58 +08:00

476 lines
14 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.
// 暖记响应式骨架 — 三级自适应布局
// 手机: 底部 TabBar + 中心凸起写日记按钮 | 平板: 侧边导航 | 桌面: 三栏
// Web 平台始终使用底部 TabBar移动端布局以保证导航交互正常
// 对齐 Open Design 原型稿: 首页/日历/写日记(FAB)/发现/我的
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../core/constants/breakpoints.dart';
import '../core/theme/app_colors.dart';
import '../core/theme/app_typography.dart';
import '../core/theme/app_radius.dart';
/// 导航项数量(不含中心 FAB
const int kNavItemCount = 4;
/// 暖记自适应 Scaffold
class ResponsiveScaffold extends StatefulWidget {
const ResponsiveScaffold({
super.key,
required this.selectedIndex,
required this.onDestinationSelected,
required this.body,
this.onCenterButtonPressed,
this.appBarTitle,
this.secondaryBody,
});
/// 当前选中的导航索引 (0-3对应首页/日历/发现/我的)
final int selectedIndex;
/// 导航项被选中回调
final ValueChanged<int> onDestinationSelected;
/// 主内容区
final Widget body;
/// 中心写日记按钮回调
final VoidCallback? onCenterButtonPressed;
final String? appBarTitle;
final Widget? secondaryBody;
@override
State<ResponsiveScaffold> createState() => _ResponsiveScaffoldState();
}
class _ResponsiveScaffoldState extends State<ResponsiveScaffold> {
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
// Web 平台:始终使用移动端底部 TabBar 布局
final deviceType = kIsWeb
? DeviceType.mobile
: Breakpoints.getDeviceType(width);
switch (deviceType) {
case DeviceType.mobile:
return _MobileLayout(
selectedIndex: widget.selectedIndex,
onDestinationSelected: widget.onDestinationSelected,
body: widget.body,
onCenterButtonPressed: widget.onCenterButtonPressed,
appBarTitle: widget.appBarTitle,
);
case DeviceType.tablet:
return _TabletLayout(
selectedIndex: widget.selectedIndex,
onDestinationSelected: widget.onDestinationSelected,
body: widget.body,
onCenterButtonPressed: widget.onCenterButtonPressed,
appBarTitle: widget.appBarTitle,
);
case DeviceType.desktop:
return _DesktopLayout(
selectedIndex: widget.selectedIndex,
onDestinationSelected: widget.onDestinationSelected,
body: widget.body,
onCenterButtonPressed: widget.onCenterButtonPressed,
secondaryBody: widget.secondaryBody,
appBarTitle: widget.appBarTitle,
);
}
}
}
// ===== 导航项定义4 项,中间由 FAB 占位)=====
const _navItems = [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.calendar_month_outlined),
selectedIcon: Icon(Icons.calendar_month),
label: '日历',
),
// 索引 2: 中心 FAB 占位(写日记)
NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: '发现',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
];
const _railItems = [
NavigationRailDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: Text('首页'),
),
NavigationRailDestination(
icon: Icon(Icons.calendar_month_outlined),
selectedIcon: Icon(Icons.calendar_month),
label: Text('日历'),
),
NavigationRailDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: Text('发现'),
),
NavigationRailDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: Text('我的'),
),
];
// ===== 中心写日记 FAB 按钮 =====
class _CenterFabButton extends StatelessWidget {
const _CenterFabButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return FloatingActionButton(
heroTag: 'center_write',
onPressed: onPressed,
backgroundColor: AppColors.accent,
foregroundColor: const Color(0xFFFFF8F0),
elevation: 4,
shape: const CircleBorder(),
child: const Icon(Icons.edit_rounded, size: 28),
);
}
}
// ===== 手机布局 — 底部 TabBar + 中心凸起 FAB =====
class _MobileLayout extends StatelessWidget {
const _MobileLayout({
required this.selectedIndex,
required this.onDestinationSelected,
required this.body,
this.onCenterButtonPressed,
this.appBarTitle,
});
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final Widget body;
final VoidCallback? onCenterButtonPressed;
final String? appBarTitle;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: appBarTitle != null
? AppBar(title: Text(appBarTitle!))
: null,
body: body,
floatingActionButton: onCenterButtonPressed != null
? _CenterFabButton(onPressed: onCenterButtonPressed!)
: null,
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: _BottomNavBar(
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
),
);
}
}
/// 自定义底部导航栏 — 支持中心凹槽
class _BottomNavBar extends StatelessWidget {
const _BottomNavBar({
required this.selectedIndex,
required this.onDestinationSelected,
});
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return BottomAppBar(
shape: const CircularNotchedRectangle(),
padding: EdgeInsets.zero,
height: 64,
color: colorScheme.surface,
surfaceTintColor: Colors.transparent,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// 首页
_NavItem(
icon: Icons.home_outlined,
activeIcon: Icons.home,
label: '首页',
isSelected: selectedIndex == 0,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(0),
),
// 日历
_NavItem(
icon: Icons.calendar_month_outlined,
activeIcon: Icons.calendar_month,
label: '日历',
isSelected: selectedIndex == 1,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(1),
),
// 中间留给 FAB 凹槽 — 占位
const SizedBox(width: 48),
// 发现
_NavItem(
icon: Icons.explore_outlined,
activeIcon: Icons.explore,
label: '发现',
isSelected: selectedIndex == 2,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(2),
),
// 我的
_NavItem(
icon: Icons.person_outline,
activeIcon: Icons.person,
label: '我的',
isSelected: selectedIndex == 3,
color: colorScheme.primary,
inactiveColor: colorScheme.onSurfaceVariant,
onTap: () => onDestinationSelected(3),
),
],
),
);
}
}
/// 单个底部导航项
class _NavItem extends StatelessWidget {
const _NavItem({
required this.icon,
required this.activeIcon,
required this.label,
required this.isSelected,
required this.color,
required this.inactiveColor,
required this.onTap,
});
final IconData icon;
final IconData activeIcon;
final String label;
final bool isSelected;
final Color color;
final Color inactiveColor;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(
width: 64,
height: 56,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isSelected ? activeIcon : icon,
size: 24,
color: isSelected ? color : inactiveColor,
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? color : inactiveColor,
),
),
],
),
),
);
}
}
// ===== 平板布局 — 侧边 NavigationRail =====
class _TabletLayout extends StatelessWidget {
const _TabletLayout({
required this.selectedIndex,
required this.onDestinationSelected,
required this.body,
this.onCenterButtonPressed,
this.appBarTitle,
});
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final Widget body;
final VoidCallback? onCenterButtonPressed;
final String? appBarTitle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: appBarTitle != null
? AppBar(title: Text(appBarTitle!))
: null,
body: Row(
children: [
NavigationRail(
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: _railItems,
leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.edit_note_rounded,
size: 32,
color: theme.colorScheme.primary,
),
const SizedBox(height: 8),
if (onCenterButtonPressed != null)
FloatingActionButton.small(
heroTag: 'rail_write',
onPressed: onCenterButtonPressed,
backgroundColor: AppColors.accent,
foregroundColor: const Color(0xFFFFF8F0),
child: const Icon(Icons.edit_rounded, size: 20),
),
],
),
),
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: body),
],
),
);
}
}
// ===== 桌面布局 — 三栏 =====
class _DesktopLayout extends StatelessWidget {
const _DesktopLayout({
required this.selectedIndex,
required this.onDestinationSelected,
required this.body,
this.onCenterButtonPressed,
this.secondaryBody,
this.appBarTitle,
});
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final Widget body;
final VoidCallback? onCenterButtonPressed;
final Widget? secondaryBody;
final String? appBarTitle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: appBarTitle != null
? AppBar(title: Text(appBarTitle!))
: null,
body: Row(
children: [
NavigationRail(
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: _railItems,
extended: true,
leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.edit_note_rounded,
size: 32,
color: theme.colorScheme.primary,
),
const SizedBox(width: 12),
Text(
'暖记',
style: theme.textTheme.headlineSmall?.copyWith(
fontFamily: AppTypography.handwrittenFont,
color: theme.colorScheme.primary,
),
),
],
),
const SizedBox(height: 12),
if (onCenterButtonPressed != null)
Padding(
padding: const EdgeInsets.only(right: 16),
child: ElevatedButton.icon(
onPressed: onCenterButtonPressed,
icon: const Icon(Icons.edit_rounded, size: 18),
label: const Text('写日记'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accent,
foregroundColor: const Color(0xFFFFF8F0),
shape: RoundedRectangleBorder(
borderRadius: AppRadius.pillBorder,
),
),
),
),
],
),
),
),
const VerticalDivider(thickness: 1, width: 1),
// 主内容区
Expanded(
flex: 3,
child: body,
),
// 第二面板(日记详情/预览)
if (secondaryBody != null) ...[
const VerticalDivider(thickness: 1, width: 1),
Expanded(
flex: 2,
child: secondaryBody!,
),
],
],
),
);
}
}