From b320641d9cbb2ca69c4d419cc6213107c4fb3818 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 2 Jun 2026 01:03:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E5=85=A8=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E9=94=99=E8=AF=AF/CORS/=E8=BF=81=E7=A7=BB/=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端修复: - 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个页面全部渲染正常 --- app/lib/core/theme/app_colors.dart | 145 ++- app/lib/core/theme/app_radius.dart | 6 +- app/lib/core/theme/app_theme.dart | 15 +- app/lib/core/theme/app_typography.dart | 73 +- .../achievement/views/achievement_page.dart | 7 +- .../calendar/views/calendar_page.dart | 519 ++++++-- .../features/calendar/views/monthly_page.dart | 634 +++++++++ .../features/calendar/views/weekly_page.dart | 573 +++++++++ app/lib/features/home/views/home_page.dart | 20 +- app/lib/features/mood/views/mood_page.dart | 7 +- .../onboarding/views/onboarding_page.dart | 495 +++++++ .../onboarding/views/splash_page.dart | 694 ++++++++++ .../features/profile/views/profile_page.dart | 8 +- .../settings/views/settings_page.dart | 9 +- .../stickers/views/sticker_library_page.dart | 7 +- .../features/teacher/views/teacher_page.dart | 5 +- .../views/template_gallery_page.dart | 7 +- app/lib/widgets/responsive_scaffold.dart | 305 ++++- config/default.toml | 2 +- crates/erp-diary/src/service/class_service.rs | 6 + .../src/m20260601_000300_diary_role_seed.rs | 7 +- docs/opendesign/css/components.css | 296 +++++ docs/opendesign/css/editor-common.css | 502 ++++++++ docs/opendesign/css/tokens.css | 235 ++++ docs/opendesign/index.html | 611 +++++++++ docs/opendesign/js/theme-switcher.js | 103 ++ docs/opendesign/screens/calendar.html | 514 ++++++++ docs/opendesign/screens/desktop/calendar.html | 629 +++++++++ docs/opendesign/screens/desktop/discover.html | 648 ++++++++++ docs/opendesign/screens/desktop/editor.html | 479 +++++++ .../screens/desktop/home-daily.html | 595 +++++++++ docs/opendesign/screens/desktop/login.html | 725 +++++++++++ docs/opendesign/screens/desktop/search.html | 535 ++++++++ docs/opendesign/screens/desktop/shared.css | 312 +++++ docs/opendesign/screens/discover.html | 555 ++++++++ docs/opendesign/screens/editor.html | 1135 +++++++++++++++++ docs/opendesign/screens/home-daily.html | 519 ++++++++ docs/opendesign/screens/login.html | 644 ++++++++++ docs/opendesign/screens/monthly.html | 306 +++++ docs/opendesign/screens/mood-tracker.html | 433 +++++++ docs/opendesign/screens/onboarding.html | 310 +++++ docs/opendesign/screens/profile.html | 330 +++++ docs/opendesign/screens/search.html | 507 ++++++++ docs/opendesign/screens/splash.html | 208 +++ docs/opendesign/screens/stickers.html | 431 +++++++ docs/opendesign/screens/tablet/calendar.html | 485 +++++++ docs/opendesign/screens/tablet/discover.html | 638 +++++++++ docs/opendesign/screens/tablet/editor.html | 892 +++++++++++++ .../opendesign/screens/tablet/home-daily.html | 456 +++++++ docs/opendesign/screens/tablet/login.html | 622 +++++++++ docs/opendesign/screens/tablet/search.html | 471 +++++++ docs/opendesign/screens/tablet/shared.css | 227 ++++ docs/opendesign/screens/templates.html | 461 +++++++ docs/opendesign/screens/weekly.html | 285 +++++ docs/opendesign/warm-notes-design-spec.md | 1121 ++++++++++++++++ scripts/dev.sh | 171 +++ 56 files changed, 20696 insertions(+), 239 deletions(-) create mode 100644 app/lib/features/calendar/views/monthly_page.dart create mode 100644 app/lib/features/calendar/views/weekly_page.dart create mode 100644 app/lib/features/onboarding/views/onboarding_page.dart create mode 100644 app/lib/features/onboarding/views/splash_page.dart create mode 100644 docs/opendesign/css/components.css create mode 100644 docs/opendesign/css/editor-common.css create mode 100644 docs/opendesign/css/tokens.css create mode 100644 docs/opendesign/index.html create mode 100644 docs/opendesign/js/theme-switcher.js create mode 100644 docs/opendesign/screens/calendar.html create mode 100644 docs/opendesign/screens/desktop/calendar.html create mode 100644 docs/opendesign/screens/desktop/discover.html create mode 100644 docs/opendesign/screens/desktop/editor.html create mode 100644 docs/opendesign/screens/desktop/home-daily.html create mode 100644 docs/opendesign/screens/desktop/login.html create mode 100644 docs/opendesign/screens/desktop/search.html create mode 100644 docs/opendesign/screens/desktop/shared.css create mode 100644 docs/opendesign/screens/discover.html create mode 100644 docs/opendesign/screens/editor.html create mode 100644 docs/opendesign/screens/home-daily.html create mode 100644 docs/opendesign/screens/login.html create mode 100644 docs/opendesign/screens/monthly.html create mode 100644 docs/opendesign/screens/mood-tracker.html create mode 100644 docs/opendesign/screens/onboarding.html create mode 100644 docs/opendesign/screens/profile.html create mode 100644 docs/opendesign/screens/search.html create mode 100644 docs/opendesign/screens/splash.html create mode 100644 docs/opendesign/screens/stickers.html create mode 100644 docs/opendesign/screens/tablet/calendar.html create mode 100644 docs/opendesign/screens/tablet/discover.html create mode 100644 docs/opendesign/screens/tablet/editor.html create mode 100644 docs/opendesign/screens/tablet/home-daily.html create mode 100644 docs/opendesign/screens/tablet/login.html create mode 100644 docs/opendesign/screens/tablet/search.html create mode 100644 docs/opendesign/screens/tablet/shared.css create mode 100644 docs/opendesign/screens/templates.html create mode 100644 docs/opendesign/screens/weekly.html create mode 100644 docs/opendesign/warm-notes-design-spec.md create mode 100644 scripts/dev.sh diff --git a/app/lib/core/theme/app_colors.dart b/app/lib/core/theme/app_colors.dart index f8a6e89..c259b8c 100644 --- a/app/lib/core/theme/app_colors.dart +++ b/app/lib/core/theme/app_colors.dart @@ -1,5 +1,6 @@ -// 暖记色彩系统 — 7 色 × 浅色/深色模式 -// 设计规格 v1.2 +// 暖记色彩系统 — 完整设计 Token +// 对齐 Open Design 原型稿 tokens.css +// 浅色(暖阳) + 深色 + 松风主题色值 import 'package:flutter/material.dart'; @@ -7,7 +8,7 @@ import 'package:flutter/material.dart'; class AppColors { AppColors._(); - // ===== 浅色模式 ===== + // ===== 核心七色 · 浅色模式(暖阳 Warm Sun)===== /// 奶油白背景 #FFF8F0 static const Color bgLight = Color(0xFFFFF8F0); @@ -30,7 +31,45 @@ class AppColors { /// 玫瑰粉 #D4A5A5 static const Color rose = Color(0xFFD4A5A5); - // ===== 深色模式 ===== + // ===== 中间色 · 浅色模式 ===== + + /// 次要文字 #5C4F47 + static const Color fg2Light = Color(0xFF5C4F47); + + /// 柔和/禁用文字 #7A6D63 + static const Color mutedLight = Color(0xFF7A6D63); + + /// 辅助说明文字 #8B7E74 + static const Color metaLight = Color(0xFF8B7E74); + + /// 边框 #E8DDD4 + static const Color borderLight = Color(0xFFE8DDD4); + + /// 柔和边框 #F0E8DF + static const Color borderSoftLight = Color(0xFFF0E8DF); + + /// 主色悬停 #D06A4F + static const Color accentHover = Color(0xFFD06A4F); + + /// 主色按下 #C05A3F + static const Color accentActive = Color(0xFFC05A3F); + + /// 主色辉光 rgba(224,122,95,0.25) + static const Color accentGlow = Color(0x40E07A5F); + + /// 温暖表面 #FFF3E6 + static const Color surfaceWarmLight = Color(0xFFFFF3E6); + + /// 鼠尾草绿柔和 #D4E8DC + static const Color secondarySoftLight = Color(0xFFD4E8DC); + + /// 暖金柔和 #FBE8C8 + static const Color tertiarySoftLight = Color(0xFFFBE8C8); + + /// 玫瑰柔和 #F0DADA + static const Color roseSoftLight = Color(0xFFF0DADA); + + // ===== 核心七色 · 深色模式 ===== /// 深色背景 #1A1614 static const Color bgDark = Color(0xFF1A1614); @@ -53,20 +92,77 @@ class AppColors { /// 深色玫瑰 #C4A0A0 static const Color roseDark = Color(0xFFC4A0A0); - // ===== 功能色 ===== + // ===== 中间色 · 深色模式 ===== - /// 错误红 - static const Color error = Color(0xFFD32F2F); + /// 深色次要文字 #C4B8AA + static const Color fg2Dark = Color(0xFFC4B8AA); - /// 成功绿 - static const Color success = Color(0xFF4CAF50); + /// 深色柔和文字 #9B8E82 + static const Color mutedDark = Color(0xFF9B8E82); - /// 警告黄 - static const Color warning = Color(0xFFFFA726); + /// 深色辅助文字 #7A6D63 + static const Color metaDark = Color(0xFF7A6D63); - /// 信息蓝 + /// 深色边框 #3A3530 + static const Color borderDark = Color(0xFF3A3530); + + /// 深色柔和边框 #302B26 + static const Color borderSoftDark = Color(0xFF302B26); + + /// 深色主色悬停 #D07A64 + static const Color accentHoverDark = Color(0xFFD07A64); + + /// 深色主色按下 #C06A54 + static const Color accentActiveDark = Color(0xFFC06A54); + + /// 深色主色辉光 + static const Color accentGlowDark = Color(0x40E8907A); + + /// 深色温暖表面 #332D28 + static const Color surfaceWarmDark = Color(0xFF332D28); + + /// 深色鼠尾草绿柔和 #2A3A2E + static const Color secondarySoftDark = Color(0xFF2A3A2E); + + /// 深色暖金柔和 #302A1E + static const Color tertiarySoftDark = Color(0xFF302A1E); + + /// 深色玫瑰柔和 #3A2A2A + static const Color roseSoftDark = Color(0xFF3A2A2A); + + // ===== 功能色(对齐设计稿)===== + + /// 错误/危险 #C93D3D + static const Color error = Color(0xFFC93D3D); + + /// 成功 #5A9E7E + static const Color success = Color(0xFF5A9E7E); + + /// 警告 #D4A843 + static const Color warning = Color(0xFFD4A843); + + /// 信息 #42A5F5 static const Color info = Color(0xFF42A5F5); + // ===== 功能色 · 深色模式 ===== + + /// 深色错误 #D94A4A + static const Color errorDark = Color(0xFFD94A4A); + + /// 深色成功 #6AAF8E + static const Color successDark = Color(0xFF6AAF8E); + + /// 深色警告 #C4A843 + static const Color warningDark = Color(0xFFC4A843); + + // ===== 阴影色调 ===== + + /// 浅色阴影 rgba(45,36,32,...) + static const Color shadowLight = Color(0xFF2D2420); + + /// 深色阴影 rgba(0,0,0,...) + static const Color shadowDark = Color(0xFF000000); + // ===== 心情颜色映射 ===== /// 心情 → 颜色 @@ -78,17 +174,26 @@ class AppColors { 'thinking': Color(0xFFB8A9C9),// 🤔 思考 — 淡紫 }; + /// 心情 → 日历背景色 + static const Map moodCellColors = { + 'happy': secondarySoftLight, // #D4E8DC + 'love': roseSoftLight, // #F0DADA + 'calm': tertiarySoftLight, // #FBE8C8 + 'sad': Color(0xFFD4DDE8), // 灰蓝 + 'tired': Color(0xFFE8E4E0), // 灰棕 + }; + // ===== 浅色主题色彩方案 ===== static const _light = ColorScheme( brightness: Brightness.light, primary: accent, - onPrimary: Colors.white, + onPrimary: Color(0xFFFFF8F0), // accent-on primaryContainer: Color(0xFFFFE0D6), onPrimaryContainer: fgLight, secondary: secondary, onSecondary: Colors.white, - secondaryContainer: Color(0xFFD4E8DC), + secondaryContainer: secondarySoftLight, onSecondaryContainer: fgLight, tertiary: tertiary, onTertiary: fgLight, @@ -96,6 +201,9 @@ class AppColors { onError: Colors.white, surface: surfaceLight, onSurface: fgLight, + onSurfaceVariant: mutedLight, // 次要文字 + outline: borderLight, // 边框 + outlineVariant: borderSoftLight, // 柔和边框 surfaceContainerHighest: bgLight, ); @@ -104,19 +212,22 @@ class AppColors { static const _dark = ColorScheme( brightness: Brightness.dark, primary: accentDark, - onPrimary: fgDark, + onPrimary: Color(0xFF1A1614), // accent-on dark primaryContainer: Color(0xFF5A2E22), onPrimaryContainer: Color(0xFFFFD6CC), secondary: secondaryDark, onSecondary: fgDark, - secondaryContainer: Color(0xFF2A4A38), + secondaryContainer: secondarySoftDark, onSecondaryContainer: Color(0xFFD4E8DC), tertiary: tertiaryDark, onTertiary: fgDark, - error: Color(0xFFEF5350), + error: errorDark, onError: fgDark, surface: surfaceDark, onSurface: fgDark, + onSurfaceVariant: mutedDark, + outline: borderDark, + outlineVariant: borderSoftDark, surfaceContainerHighest: bgDark, ); diff --git a/app/lib/core/theme/app_radius.dart b/app/lib/core/theme/app_radius.dart index 5b754f7..e426020 100644 --- a/app/lib/core/theme/app_radius.dart +++ b/app/lib/core/theme/app_radius.dart @@ -1,11 +1,15 @@ // 暖记圆角系统 -// 设计规格: 10 / 16 / 22 / 28 / pill +// 对齐 Open Design 原型稿 tokens.css: xs(8) / sm(10) / md(16) / lg(22) / xl(28) / pill import 'package:flutter/material.dart'; class AppRadius { AppRadius._(); + /// 极小圆角 8px — 小型元素 + static const double xs = 8; + static BorderRadius get xsBorder => BorderRadius.circular(xs); + /// 小圆角 10px — 按钮、输入框 static const double sm = 10; static BorderRadius get smBorder => BorderRadius.circular(sm); diff --git a/app/lib/core/theme/app_theme.dart b/app/lib/core/theme/app_theme.dart index 64b3d4f..3c41ba8 100644 --- a/app/lib/core/theme/app_theme.dart +++ b/app/lib/core/theme/app_theme.dart @@ -1,4 +1,5 @@ // 暖记主题入口 — 浅色/深色 ThemeData +// 对齐 Open Design 原型稿设计系统 import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; @@ -44,7 +45,7 @@ class AppTheme { shape: RoundedRectangleBorder( borderRadius: AppRadius.mdBorder, side: BorderSide( - color: colorScheme.outlineVariant.withValues(alpha: 0.3), + color: colorScheme.outlineVariant, ), ), color: colorScheme.surface, @@ -102,7 +103,7 @@ class AppTheme { bottomNavigationBarTheme: BottomNavigationBarThemeData( type: BottomNavigationBarType.fixed, selectedItemColor: colorScheme.primary, - unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.5), + unselectedItemColor: colorScheme.onSurfaceVariant, backgroundColor: colorScheme.surface, elevation: 8, ), @@ -115,15 +116,15 @@ class AppTheme { side: BorderSide.none, ), - // FloatingActionButton + // FloatingActionButton — 珊瑚色圆形凸起 floatingActionButtonTheme: FloatingActionButtonThemeData( - shape: RoundedRectangleBorder( - borderRadius: AppRadius.mdBorder, - ), + backgroundColor: isLight ? AppColors.accent : AppColors.accentDark, + foregroundColor: isLight ? const Color(0xFFFFF8F0) : const Color(0xFF1A1614), + shape: const CircleBorder(), elevation: 4, ), - // Page transitions — 弹性曲线 + // Page transitions — 弹性曲线 cubic-bezier(0.34, 1.56, 0.64, 1) pageTransitionsTheme: PageTransitionsTheme( builders: { TargetPlatform.android: _WarmCurveBuilder(), diff --git a/app/lib/core/theme/app_typography.dart b/app/lib/core/theme/app_typography.dart index eb13d6e..f837d57 100644 --- a/app/lib/core/theme/app_typography.dart +++ b/app/lib/core/theme/app_typography.dart @@ -1,4 +1,6 @@ -// 暖记字体系统 — Noto Sans SC + Caveat +// 暖记字体系统 — Quicksand(标题) + Nunito(正文) + Caveat(手写装饰) +// 对齐 Open Design 原型稿 tokens.css +// 字体文件待下载,当前系统字体回退 import 'package:flutter/material.dart'; @@ -6,99 +8,102 @@ class AppTypography { AppTypography._(); /// 字体族 - static const String displayFont = 'Caveat'; // 手写风格(标题装饰) - static const String bodyFont = 'NotoSansSC'; // 正文(中文优先) - static const String monoFont = 'JetBrains Mono'; // 等宽(暂用系统回退) + static const String displayFont = 'Quicksand'; // 标题显示(待下载,系统回退 sans-serif) + static const String bodyFont = 'Nunito'; // 正文(待下载,系统回退 sans-serif) + static const String handwrittenFont = 'Caveat'; // 手写装饰(已有字体文件) + static const String cjkFont = 'NotoSansSC'; // CJK 回退(已有字体文件) + static const String monoFont = 'JetBrains Mono'; // 等宽(暂用系统回退) /// 浅色主题文字主题 + /// 字号对齐设计稿: xs=11, sm=13, base=15, md=17, lg=20, xl=24, 2xl=30, 3xl=38, 4xl=48 static TextTheme lightTextTheme() => TextTheme( - // 大标题 — 手写风格 + // Display — Quicksand 标题(对应 text-3xl / text-4xl) displayLarge: TextStyle( fontFamily: displayFont, - fontSize: 57, + fontSize: 48, height: 1.12, fontWeight: FontWeight.w700, ), displayMedium: TextStyle( fontFamily: displayFont, - fontSize: 45, + fontSize: 38, height: 1.16, fontWeight: FontWeight.w700, ), displaySmall: TextStyle( fontFamily: displayFont, - fontSize: 36, - height: 1.22, + fontSize: 30, + height: 1.2, fontWeight: FontWeight.w700, ), - // 标题 — 正文衬线 + // Headline — Nunito 标题(对应 text-xl / text-2xl) headlineLarge: TextStyle( fontFamily: bodyFont, - fontSize: 32, + fontSize: 30, height: 1.25, fontWeight: FontWeight.w700, ), headlineMedium: TextStyle( fontFamily: bodyFont, - fontSize: 28, + fontSize: 24, height: 1.29, fontWeight: FontWeight.w600, ), headlineSmall: TextStyle( fontFamily: bodyFont, - fontSize: 24, - height: 1.33, + fontSize: 20, + height: 1.3, fontWeight: FontWeight.w600, ), - // 副标题 + // Title — Nunito 副标题(对应 text-md / text-base) titleLarge: TextStyle( fontFamily: bodyFont, - fontSize: 22, - height: 1.27, + fontSize: 17, + height: 1.3, fontWeight: FontWeight.w600, ), titleMedium: TextStyle( fontFamily: bodyFont, - fontSize: 16, - height: 1.5, + fontSize: 15, + height: 1.6, fontWeight: FontWeight.w600, ), titleSmall: TextStyle( fontFamily: bodyFont, - fontSize: 14, - height: 1.43, + fontSize: 13, + height: 1.4, fontWeight: FontWeight.w600, ), - // 正文 + // Body — Nunito 正文(对应 text-base / text-sm) bodyLarge: TextStyle( fontFamily: bodyFont, - fontSize: 16, - height: 1.5, + fontSize: 15, + height: 1.6, fontWeight: FontWeight.w400, ), bodyMedium: TextStyle( fontFamily: bodyFont, - fontSize: 14, - height: 1.43, + fontSize: 15, + height: 1.6, fontWeight: FontWeight.w400, ), bodySmall: TextStyle( fontFamily: bodyFont, - fontSize: 12, - height: 1.33, + fontSize: 13, + height: 1.6, fontWeight: FontWeight.w400, ), - // 标签 + // Label — Nunito 标签(对应 text-base / text-sm / text-xs) labelLarge: TextStyle( fontFamily: bodyFont, - fontSize: 14, - height: 1.43, + fontSize: 15, + height: 1.6, fontWeight: FontWeight.w500, ), labelMedium: TextStyle( fontFamily: bodyFont, - fontSize: 12, - height: 1.33, + fontSize: 13, + height: 1.6, fontWeight: FontWeight.w500, ), labelSmall: TextStyle( @@ -109,6 +114,6 @@ class AppTypography { ), ); - /// 深色主题文字主题 + /// 深色主题文字主题(与浅色共享字号,颜色由 ColorScheme 控制) static TextTheme darkTextTheme() => lightTextTheme(); } diff --git a/app/lib/features/achievement/views/achievement_page.dart b/app/lib/features/achievement/views/achievement_page.dart index f789cf8..429e259 100644 --- a/app/lib/features/achievement/views/achievement_page.dart +++ b/app/lib/features/achievement/views/achievement_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/data/remote/api_client.dart'; import '../bloc/achievement_bloc.dart'; @@ -129,7 +130,7 @@ class _AchievementProgressCard extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), + borderRadius: AppRadius.lgBorder, ), color: colorScheme.primaryContainer, child: Padding( @@ -156,7 +157,7 @@ class _AchievementProgressCard extends StatelessWidget { ), const SizedBox(height: 12), ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: AppRadius.xsBorder, child: LinearProgressIndicator( value: progress, minHeight: 10, @@ -188,7 +189,7 @@ class _AchievementCard extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, side: BorderSide( color: achievement.isUnlocked ? AppColors.accent.withValues(alpha: 0.4) diff --git a/app/lib/features/calendar/views/calendar_page.dart b/app/lib/features/calendar/views/calendar_page.dart index 4bf6c59..643964b 100644 --- a/app/lib/features/calendar/views/calendar_page.dart +++ b/app/lib/features/calendar/views/calendar_page.dart @@ -1,14 +1,16 @@ -// 日历页面 — 月视图 + 日记列表 +// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴 +// 对齐 Open Design 原型稿: 日历格子用心情颜色填充背景 + 月/周/时间轴切换 import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/repositories/journal_repository.dart'; import '../bloc/calendar_bloc.dart'; -/// 日历页面 — 月视图 + 选中日期的日记列表 +/// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴 class CalendarPage extends StatelessWidget { const CalendarPage({super.key}); @@ -80,27 +82,33 @@ class _CalendarView extends StatelessWidget { }, ), - // 星期标题行 - _WeekdayHeader(colorScheme: colorScheme), - - // 日历网格 - _CalendarGrid( - month: loaded.focusedMonth, - selectedDay: loaded.selectedDay, - journalsByDate: loaded.journalsByDate, - onDaySelected: (day) { - context.read().add(CalendarDaySelected(day)); - }, + // 视图模式切换 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: SegmentedButton( + segments: const [ + ButtonSegment(value: CalendarViewMode.month, label: Text('月')), + ButtonSegment(value: CalendarViewMode.week, label: Text('周')), + ButtonSegment(value: CalendarViewMode.timeline, label: Text('时间轴')), + ], + selected: {loaded.viewMode}, + onSelectionChanged: (modes) { + context.read() + .add(CalendarViewModeChanged(modes.first)); + }, + style: ButtonStyle( + visualDensity: VisualDensity.compact, + textStyle: WidgetStatePropertyAll(theme.textTheme.labelSmall), + ), + ), ), - const Divider(height: 1), - - // 选中日期的日记列表 - Expanded( - child: loaded.selectedDayJournals.isEmpty - ? _EmptyDayView(selectedDay: loaded.selectedDay) - : _DayJournalList(journals: loaded.selectedDayJournals), - ), + // 根据视图模式切换内容 + switch (loaded.viewMode) { + CalendarViewMode.month => _MonthView(loaded: loaded), + CalendarViewMode.week => _WeekView(loaded: loaded), + CalendarViewMode.timeline => _TimelineView(loaded: loaded), + }, ], ); }, @@ -108,7 +116,295 @@ class _CalendarView extends StatelessWidget { } } -/// 月份导航栏 +// ===== 月视图 ===== + +class _MonthView extends StatelessWidget { + const _MonthView({required this.loaded}); + + final CalendarLoaded loaded; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + children: [ + // 星期标题行 + _WeekdayHeader(colorScheme: Theme.of(context).colorScheme), + + // 日历网格 + _CalendarGrid( + month: loaded.focusedMonth, + selectedDay: loaded.selectedDay, + journalsByDate: loaded.journalsByDate, + onDaySelected: (day) { + context.read().add(CalendarDaySelected(day)); + }, + ), + + const Divider(height: 1), + + // 选中日期的日记列表 + Expanded( + child: loaded.selectedDayJournals.isEmpty + ? _EmptyDayView(selectedDay: loaded.selectedDay) + : _DayJournalList(journals: loaded.selectedDayJournals), + ), + ], + ), + ); + } +} + +// ===== 周视图 ===== + +class _WeekView extends StatelessWidget { + const _WeekView({required this.loaded}); + + final CalendarLoaded loaded; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + // 计算选中日期所在周 + final selected = loaded.selectedDay; + final startOfWeek = selected.subtract(Duration(days: selected.weekday - 1)); + + return Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // 7 天条目 + ...List.generate(7, (i) { + final day = startOfWeek.add(Duration(days: i)); + final dayKey = DateTime(day.year, day.month, day.day); + final journals = loaded.journalsByDate[dayKey] ?? []; + final isToday = _isToday(day); + final isSelected = _isSameDay(day, loaded.selectedDay); + final hasEntry = journals.isNotEmpty; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: () { + context.read().add(CalendarDaySelected(day)); + }, + borderRadius: AppRadius.mdBorder, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primaryContainer + : hasEntry + ? _getMoodBgColor(journals.first.mood.value) + : colorScheme.surface, + borderRadius: AppRadius.mdBorder, + border: Border.all( + color: isToday + ? colorScheme.primary + : colorScheme.outlineVariant, + width: isToday ? 2 : 1, + ), + ), + child: Row( + children: [ + // 日期 + SizedBox( + width: 48, + child: Column( + children: [ + Text( + _weekdayName(day.weekday), + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + Text( + '${day.day}', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: isToday ? FontWeight.bold : FontWeight.normal, + color: isToday ? colorScheme.primary : null, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + // 内容 + Expanded( + child: hasEntry + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + journals.first.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (journals.length > 1) + Text( + '还有 ${journals.length - 1} 篇日记', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ) + : Text( + '无日记', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + ), + // 心情 emoji + if (hasEntry) + Text( + _moodEmoji(journals.first.mood), + style: const TextStyle(fontSize: 24), + ), + ], + ), + ), + ), + ); + }), + ], + ), + ); + } +} + +// ===== 时间轴视图 ===== + +class _TimelineView extends StatelessWidget { + const _TimelineView({required this.loaded}); + + final CalendarLoaded loaded; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + // 获取当月所有日记,按日期排序 + final allJournals = loaded.journalsByDate.entries + .expand((e) => e.value.map((j) => MapEntry(e.key, j))) + .toList() + ..sort((a, b) => b.key.compareTo(a.key)); + + if (allJournals.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.timeline_outlined, size: 48, + color: colorScheme.onSurface.withValues(alpha: 0.2)), + const SizedBox(height: 16), + Text('本月还没有日记', style: theme.textTheme.bodyLarge), + ], + ), + ); + } + + return Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: allJournals.length, + itemBuilder: (context, index) { + final entry = allJournals[index]; + final date = entry.key; + final journal = entry.value; + final isLast = index == allJournals.length - 1; + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 时间轴线 + SizedBox( + width: 60, + child: Column( + children: [ + // 圆点 + emoji + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: _getMoodBgColor(journal.mood.value), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 18)), + ), + // 竖线 + if (!isLast) + Expanded( + child: Container( + width: 2, + color: colorScheme.outlineVariant, + ), + ), + ], + ), + ), + // 内容卡片 + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.mdBorder, + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: InkWell( + onTap: () => context.push('/editor?id=${journal.id}'), + borderRadius: AppRadius.mdBorder, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${date.month}月${date.day}日', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + journal.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // 日记内容通过 JournalElement 管理,日历视图仅显示标题 + // 后续可通过 elements 预览首段文字 + ], + ), + ), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +// ===== 月份导航栏 ===== + class _MonthNavigator extends StatelessWidget { const _MonthNavigator({ required this.month, @@ -127,7 +423,7 @@ class _MonthNavigator extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( + child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( @@ -160,7 +456,8 @@ class _MonthNavigator extends StatelessWidget { } } -/// 星期标题行 +// ===== 星期标题行 ===== + class _WeekdayHeader extends StatelessWidget { const _WeekdayHeader({required this.colorScheme}); @@ -191,7 +488,8 @@ class _WeekdayHeader extends StatelessWidget { } } -/// 日历网格 — 6行7列 +// ===== 日历网格 — 6行7列(带心情色彩背景)===== + class _CalendarGrid extends StatelessWidget { const _CalendarGrid({ required this.month, @@ -207,8 +505,6 @@ class _CalendarGrid extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final today = DateTime.now(); final days = _generateDays(month); return Padding( @@ -217,19 +513,18 @@ class _CalendarGrid extends StatelessWidget { children: days.map((week) { return Row( children: week.map((dayInfo) { + final dayKey = DateTime( + dayInfo.date.year, dayInfo.date.month, dayInfo.date.day); + final journals = journalsByDate[dayKey] ?? []; + final moodValue = journals.isNotEmpty ? journals.first.mood.value : null; + return Expanded( child: _DayCell( dayInfo: dayInfo, - isToday: dayInfo.date.year == today.year && - dayInfo.date.month == today.month && - dayInfo.date.day == today.day, - isSelected: dayInfo.date.year == selectedDay.year && - dayInfo.date.month == selectedDay.month && - dayInfo.date.day == selectedDay.day, - hasJournals: journalsByDate.containsKey( - DateTime(dayInfo.date.year, dayInfo.date.month, dayInfo.date.day), - ), - colorScheme: colorScheme, + isToday: _isToday(dayInfo.date), + isSelected: _isSameDay(dayInfo.date, selectedDay), + hasJournals: journals.isNotEmpty, + moodValue: moodValue, onTap: () => onDaySelected(dayInfo.date), ), ); @@ -240,35 +535,26 @@ class _CalendarGrid extends StatelessWidget { ); } - /// 生成当月日历数据(包含前后补齐) List> _generateDays(DateTime month) { final firstDay = DateTime(month.year, month.month, 1); - // 周一为第一天:weekday 1=Mon...7=Sun final startOffset = firstDay.weekday - 1; final daysInMonth = DateTime(month.year, month.month + 1, 0).day; final allDays = <_DayInfo>[]; - // 前面的空白 for (var i = 0; i < startOffset; i++) { allDays.add(_DayInfo(date: firstDay.subtract(Duration(days: startOffset - i)), isCurrentMonth: false)); } - // 当月日期 for (var d = 1; d <= daysInMonth; d++) { - allDays.add(_DayInfo( - date: DateTime(month.year, month.month, d), - isCurrentMonth: true, - )); + allDays.add(_DayInfo(date: DateTime(month.year, month.month, d), isCurrentMonth: true)); } - // 后面的补齐到完整行 while (allDays.length % 7 != 0) { final last = allDays.last.date; allDays.add(_DayInfo(date: last.add(const Duration(days: 1)), isCurrentMonth: false)); } - // 分周 return List.generate(allDays.length ~/ 7, (i) => allDays.sublist(i * 7, (i + 1) * 7)); } } @@ -279,14 +565,15 @@ class _DayInfo { final bool isCurrentMonth; } -/// 单日格子 +// ===== 单日格子(带心情色彩背景)===== + class _DayCell extends StatelessWidget { const _DayCell({ required this.dayInfo, required this.isToday, required this.isSelected, required this.hasJournals, - required this.colorScheme, + required this.moodValue, required this.onTap, }); @@ -294,56 +581,63 @@ class _DayCell extends StatelessWidget { final bool isToday; final bool isSelected; final bool hasJournals; - final ColorScheme colorScheme; + final String? moodValue; final VoidCallback onTap; @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + // 心情色彩背景 + final moodBg = hasJournals && moodValue != null + ? _getMoodBgColor(moodValue!) + : Colors.transparent; + return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Container( - height: 44, + height: 48, + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : moodBg != Colors.transparent + ? moodBg + : isToday + ? colorScheme.primaryContainer + : null, + borderRadius: AppRadius.xsBorder, + border: isToday && !isSelected + ? Border.all(color: colorScheme.primary, width: 2) + : null, + ), alignment: Alignment.center, child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isSelected - ? colorScheme.primary - : isToday - ? colorScheme.primaryContainer - : null, - ), - alignment: Alignment.center, - child: Text( - '${dayInfo.date.day}', - style: TextStyle( - fontSize: 14, - fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal, - color: !dayInfo.isCurrentMonth - ? colorScheme.onSurface.withValues(alpha: 0.3) - : isSelected - ? colorScheme.onPrimary - : colorScheme.onSurface, - ), + Text( + '${dayInfo.date.day}', + style: TextStyle( + fontSize: 14, + fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal, + color: !dayInfo.isCurrentMonth + ? colorScheme.onSurface.withValues(alpha: 0.3) + : isSelected + ? colorScheme.onPrimary + : colorScheme.onSurface, ), ), - // 日记指示点 - if (hasJournals) + // 心情小圆点(仅在有日记但无背景色时显示) + if (hasJournals && moodBg == Colors.transparent) Container( width: 4, height: 4, - margin: const EdgeInsets.only(top: 2), + margin: const EdgeInsets.only(top: 1), decoration: BoxDecoration( shape: BoxShape.circle, - color: isSelected - ? colorScheme.onPrimary - : AppColors.accent, + color: isSelected ? colorScheme.onPrimary : AppColors.accent, ), ), ], @@ -353,7 +647,8 @@ class _DayCell extends StatelessWidget { } } -/// 无日记的空状态 +// ===== 无日记的空状态 ===== + class _EmptyDayView extends StatelessWidget { const _EmptyDayView({required this.selectedDay}); @@ -394,7 +689,8 @@ class _EmptyDayView extends StatelessWidget { } } -/// 日记列表 +// ===== 日记列表 ===== + class _DayJournalList extends StatelessWidget { const _DayJournalList({required this.journals}); @@ -416,17 +712,16 @@ class _DayJournalList extends StatelessWidget { margin: const EdgeInsets.only(bottom: 12), elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, side: BorderSide(color: colorScheme.outlineVariant), ), child: InkWell( onTap: () => context.push('/editor?id=${journal.id}'), - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ - // 心情图标 Container( width: 40, height: 40, @@ -441,7 +736,6 @@ class _DayJournalList extends StatelessWidget { ), ), const SizedBox(width: 12), - // 标题和标签 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -478,14 +772,47 @@ class _DayJournalList extends StatelessWidget { }, ); } - - String _moodEmoji(Mood mood) { - return switch (mood) { - Mood.happy => '😊', - Mood.calm => '😌', - Mood.sad => '😢', - Mood.angry => '😠', - Mood.thinking => '🤔', - }; - } +} + +// ===== 辅助函数 ===== + +/// 获取心情对应的日历格子背景色 +Color _getMoodBgColor(String mood) { + return AppColors.moodCellColors[mood] ?? AppColors.secondarySoftLight; +} + +/// 心情 → emoji +String _moodEmoji(Mood mood) { + return switch (mood) { + Mood.happy => '😊', + Mood.calm => '😌', + Mood.sad => '😢', + Mood.angry => '😠', + Mood.thinking => '🤔', + }; +} + +/// 是否是今天 +bool _isToday(DateTime date) { + final now = DateTime.now(); + return date.year == now.year && date.month == now.month && date.day == now.day; +} + +/// 是否同一天 +bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; +} + +/// 周几名称 +String _weekdayName(int weekday) { + return switch (weekday) { + 1 => '周一', + 2 => '周二', + 3 => '周三', + 4 => '周四', + 5 => '周五', + 6 => '周六', + 7 => '周日', + _ => '', + }; } diff --git a/app/lib/features/calendar/views/monthly_page.dart b/app/lib/features/calendar/views/monthly_page.dart new file mode 100644 index 0000000..32451d9 --- /dev/null +++ b/app/lib/features/calendar/views/monthly_page.dart @@ -0,0 +1,634 @@ +// 月度概览页面 — 心情色彩月历 + 月度统计 + 精选日记 +// 对齐 Open Design 原型稿 screens/monthly.html + +import 'package:flutter/material.dart'; +import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/core/theme/app_radius.dart'; +import 'package:nuanji_app/core/theme/app_shadows.dart'; +import 'package:nuanji_app/core/theme/app_typography.dart'; + +/// 月度概览页面 +class MonthlyPage extends StatefulWidget { + const MonthlyPage({super.key}); + + @override + State createState() => _MonthlyPageState(); +} + +class _MonthlyPageState extends State { + late DateTime _focusedMonth; + + @override + void initState() { + super.initState(); + _focusedMonth = DateTime.now(); + } + + void _goToPreviousMonth() { + setState(() { + _focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1); + }); + } + + void _goToNextMonth() { + setState(() { + _focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + children: [ + // 月头部导航 + _MonthHeader( + month: _focusedMonth, + onPrevious: _goToPreviousMonth, + onNext: _goToNextMonth, + ), + // 可滚动内容区 + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + const SizedBox(height: 16), + // 心情色彩月历 + _MoodCalendar(month: _focusedMonth), + const SizedBox(height: 20), + // 月度统计 2x2 + const _MonthSummary(), + const SizedBox(height: 20), + // 精选日记 + const _Highlights(), + const SizedBox(height: 32), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ===== 月头部导航 ===== + +class _MonthHeader extends StatelessWidget { + const _MonthHeader({ + required this.month, + required this.onPrevious, + required this.onNext, + }); + + final DateTime month; + final VoidCallback onPrevious; + final VoidCallback onNext; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final title = '${month.year}年${month.month}月'; + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 12, 0), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + fontFamily: AppTypography.displayFont, + fontWeight: FontWeight.w700, + ), + ), + ), + _NavButton( + icon: Icons.chevron_left_rounded, + onTap: onPrevious, + borderColor: colorScheme.outline, + foregroundColor: colorScheme.onSurface, + ), + const SizedBox(width: 8), + _NavButton( + icon: Icons.chevron_right_rounded, + onTap: onNext, + borderColor: colorScheme.outline, + foregroundColor: colorScheme.onSurface, + ), + ], + ), + ); + } +} + +/// 圆形导航按钮 (44px 触摸目标) +class _NavButton extends StatelessWidget { + const _NavButton({ + required this.icon, + required this.onTap, + required this.borderColor, + required this.foregroundColor, + }); + + final IconData icon; + final VoidCallback onTap; + final Color borderColor; + final Color foregroundColor; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 44, + height: 44, + child: OutlinedButton( + onPressed: onTap, + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + shape: const CircleBorder(), + side: BorderSide(color: borderColor, width: 1.5), + foregroundColor: foregroundColor, + backgroundColor: Theme.of(context).colorScheme.surface, + ), + child: Icon(icon, size: 18), + ), + ); + } +} + +// ===== 心情色彩月历 ===== + +class _MoodCalendar extends StatelessWidget { + const _MoodCalendar({required this.month}); + + final DateTime month; + + // 心情类型 + static const _moodTypes = [ + 'happy', 'calm', 'sad', 'tired', 'love', + ]; + + // 心情 → emoji + static const _moodEmojis = { + 'happy': '😊', + 'calm': '😐', + 'sad': '😢', + 'tired': '😐', + 'love': '😡', + }; + + // 心情 → 背景色 + static const _moodBgColors = { + 'happy': AppColors.secondarySoftLight, + 'love': AppColors.roseSoftLight, + 'calm': AppColors.tertiarySoftLight, + 'sad': Color(0xFFD4DDE8), + 'tired': Color(0xFFE8E4E0), + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final now = DateTime.now(); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: AppRadius.mdBorder, + boxShadow: AppShadows.soft(context), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + children: [ + // 星期标题行 + _WeekdayRow(colorScheme: colorScheme), + const SizedBox(height: 8), + // 7列网格 + _buildGrid(context, now), + ], + ), + ); + } + + Widget _buildGrid(BuildContext context, DateTime now) { + final firstDay = DateTime(month.year, month.month, 1); + // 周日=0 → 偏移量; weekday 返回 1(周一)..7(周日) + final startOffset = firstDay.weekday % 7; // 周日开头 + final daysInMonth = DateTime(month.year, month.month + 1, 0).day; + + final cells = []; + + // 空白填充 + for (var i = 0; i < startOffset; i++) { + cells.add(const SizedBox.shrink()); + } + + // 模拟心情数据(确定性伪随机,同一天固定同心情) + final rng = _SeededRandom(month.year * 100 + month.month); + + for (var d = 1; d <= daysInMonth; d++) { + final isToday = now.year == month.year && + now.month == month.month && + now.day == d; + + // 每天随机一个心情 + final moodIndex = rng.nextInt(5); + final mood = _moodTypes[moodIndex]; + final bgColor = _moodBgColors[mood] ?? Colors.transparent; + final emoji = _moodEmojis[mood] ?? ''; + + cells.add( + _MoodCell( + day: d, + emoji: emoji, + bgColor: bgColor, + isToday: isToday, + ), + ); + } + + return GridView.count( + crossAxisCount: 7, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 3, + crossAxisSpacing: 3, + childAspectRatio: 1, + children: cells, + ); + } +} + +/// 简单确定性伪随机数生成器(仅用于模拟数据) +class _SeededRandom { + _SeededRandom(int seed) : _state = seed; + int _state; + + int nextInt(int max) { + _state = (_state * 1103515245 + 12345) & 0x7FFFFFFF; + return _state % max; + } +} + +/// 星期标题行 +class _WeekdayRow extends StatelessWidget { + const _WeekdayRow({required this.colorScheme}); + + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + const weekdays = ['日', '一', '二', '三', '四', '五', '六']; + return Row( + children: weekdays.map((day) { + return Expanded( + child: Center( + child: Text( + day, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ); + }).toList(), + ); + } +} + +/// 单个心情格子 +class _MoodCell extends StatelessWidget { + const _MoodCell({ + required this.day, + required this.emoji, + required this.bgColor, + required this.isToday, + }); + + final int day; + final String emoji; + final Color bgColor; + final bool isToday; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return GestureDetector( + onTap: () { + // TODO: 选择日期,跳转详情 + }, + child: Container( + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(6), + border: isToday + ? Border.all(color: AppColors.accent, width: 2) + : null, + ), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$day', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurface, + fontWeight: isToday ? FontWeight.w700 : FontWeight.w400, + ), + ), + const SizedBox(height: 1), + Text( + emoji, + style: const TextStyle(fontSize: 10), + ), + ], + ), + ), + ); + } +} + +// ===== 月度统计 2x2 ===== + +class _MonthSummary extends StatelessWidget { + const _MonthSummary(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '本月总结', + style: theme.textTheme.headlineSmall?.copyWith( + fontFamily: AppTypography.displayFont, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 16), + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.2, + children: [ + _StatCard( + icon: '📝', + value: '28', + label: '日记篇数', + bgColor: AppColors.tertiarySoftLight, + valueColor: const Color(0xFFB8860B), + ), + _StatCard( + icon: '🔥', + value: '12', + label: '最长连续', + bgColor: AppColors.secondarySoftLight, + valueColor: const Color(0xFF2D7D46), + ), + _StatCard( + icon: '😊', + value: '72%', + label: '好心情占比', + bgColor: AppColors.roseSoftLight, + valueColor: const Color(0xFF9B4D4D), + ), + _StatCard( + icon: '📸', + value: '18', + label: '照片数量', + bgColor: const Color(0xFFD4DDE8), + valueColor: const Color(0xFF4A6B8A), + ), + ], + ), + ], + ); + } +} + +/// 单张统计小卡片 +class _StatCard extends StatelessWidget { + const _StatCard({ + required this.icon, + required this.value, + required this.label, + required this.bgColor, + required this.valueColor, + }); + + final String icon; + final String value; + final String label; + final Color bgColor; + final Color valueColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: bgColor, + borderRadius: AppRadius.mdBorder, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(icon, style: const TextStyle(fontSize: 24)), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontFamily: AppTypography.displayFont, + fontSize: 24, + fontWeight: FontWeight.w700, + color: valueColor, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +// ===== 精选日记 ===== + +class _Highlights extends StatelessWidget { + const _Highlights(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + // 模拟精选日记数据 + const highlights = [ + ( + emoji: '😊', + emojiBg: AppColors.roseSoftLight, + date: '5月14日', + title: '和朋友聚餐的欢乐时光', + badge: '最佳心情', + badgeBg: AppColors.roseSoftLight, + badgeFg: Color(0xFF9B4D4D), + ), + ( + emoji: '⭐', + emojiBg: AppColors.tertiarySoftLight, + date: '5月21日', + title: '完成了第一个小目标', + badge: '里程碑', + badgeBg: AppColors.tertiarySoftLight, + badgeFg: Color(0xFFB8860B), + ), + ( + emoji: '📚', + emojiBg: AppColors.secondarySoftLight, + date: '5月28日', + title: '期末考试结束', + badge: '最详尽记录', + badgeBg: AppColors.secondarySoftLight, + badgeFg: Color(0xFF2D7D46), + ), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '本月精选', + style: theme.textTheme.headlineSmall?.copyWith( + fontFamily: AppTypography.displayFont, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 16), + ...highlights.map((item) { + return _HighlightCard( + emoji: item.emoji, + emojiBg: item.emojiBg, + date: item.date, + title: item.title, + badge: item.badge, + badgeBg: item.badgeBg, + badgeFg: item.badgeFg, + ); + }), + ], + ); + } +} + +/// 单张精选日记卡片 +class _HighlightCard extends StatelessWidget { + const _HighlightCard({ + required this.emoji, + required this.emojiBg, + required this.date, + required this.title, + required this.badge, + required this.badgeBg, + required this.badgeFg, + }); + + final String emoji; + final Color emojiBg; + final String date; + final String title; + final String badge; + final Color badgeBg; + final Color badgeFg; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: AppRadius.mdBorder, + boxShadow: AppShadows.soft(context), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Row( + children: [ + // 48x48 emoji 圆圈 + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: emojiBg, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text(emoji, style: const TextStyle(fontSize: 24)), + ), + const SizedBox(width: 12), + // 标题 + 日期 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + date, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontFamily: AppTypography.displayFont, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // badge pill + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: badgeBg, + borderRadius: AppRadius.pillBorder, + ), + child: Text( + badge, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: badgeFg, + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/calendar/views/weekly_page.dart b/app/lib/features/calendar/views/weekly_page.dart new file mode 100644 index 0000000..3906ab3 --- /dev/null +++ b/app/lib/features/calendar/views/weekly_page.dart @@ -0,0 +1,573 @@ +// 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片 +// 对齐 Open Design 原型稿 screens/weekly.html + +import 'package:flutter/material.dart'; +import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/core/theme/app_radius.dart'; +import 'package:nuanji_app/core/theme/app_shadows.dart'; +import 'package:nuanji_app/core/theme/app_typography.dart'; + +/// 周概览页面 +class WeeklyPage extends StatefulWidget { + const WeeklyPage({super.key}); + + @override + State createState() => _WeeklyPageState(); +} + +class _WeeklyPageState extends State { + late DateTime _focusedWeekStart; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _focusedWeekStart = now.subtract(Duration(days: now.weekday - 1)); + } + + void _goToPreviousWeek() { + setState(() { + _focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7)); + }); + } + + void _goToNextWeek() { + setState(() { + _focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7)); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + body: SafeArea( + child: Column( + children: [ + // 周头部导航 + _WeekHeader( + weekStart: _focusedWeekStart, + onPrevious: _goToPreviousWeek, + onNext: _goToNextWeek, + ), + // 可滚动内容区 + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + const SizedBox(height: 16), + // 7天条目 + _WeekStrip(weekStart: _focusedWeekStart), + const SizedBox(height: 20), + // 本周总结 + const _WeekSummary(), + const SizedBox(height: 20), + // 每日日记卡片 + ..._buildDayCards(theme, colorScheme), + const SizedBox(height: 32), + ], + ), + ), + ], + ), + ), + ); + } + + List _buildDayCards(ThemeData theme, ColorScheme colorScheme) { + // 模拟数据: 3 张日记卡片 + return [ + _DayCard( + weekday: '周日', + date: '5月31日', + moodEmoji: '😊', + weatherEmoji: '☀️', + body: + '今天下午去图书馆自习,阳光从窗外洒进来,暖暖的。喝了抹茶拿铁,虽然期末压力大但看到窗外的樱花还在开,觉得一切都会好的。', + tags: const [ + ('学习', AppColors.secondarySoftLight, Color(0xFF2D7D46)), + ('美食', AppColors.tertiarySoftLight, Color(0xFFB8860B)), + ], + photoEmoji: '📚', + ), + _DayCard( + weekday: '周六', + date: '5月30日', + moodEmoji: '😊', + weatherEmoji: '🌤', + body: + '今天在图书馆自习,窗外的阳光洒进来,暖暖的。复习了高数第三章,做了两套模拟题感觉还不错。', + tags: const [ + ('学习', AppColors.secondarySoftLight, Color(0xFF2D7D46)), + ], + photoEmoji: null, + ), + _DayCard( + weekday: '周五', + date: '5月29日', + moodEmoji: '😊', + weatherEmoji: '☀️', + body: + '考完试和舍友们去吃了火锅庆祝,大家都好开心,聊了很多有趣的事。这学期终于结束了!', + tags: const [ + ('朋友', AppColors.roseSoftLight, Color(0xFF9B4D4D)), + ('美食', AppColors.tertiarySoftLight, Color(0xFFB8860B)), + ], + photoEmoji: '🍲', + ), + ]; + } +} + +// ===== 周头部导航 ===== + +class _WeekHeader extends StatelessWidget { + const _WeekHeader({ + required this.weekStart, + required this.onPrevious, + required this.onNext, + }); + + final DateTime weekStart; + final VoidCallback onPrevious; + final VoidCallback onNext; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + // 格式化: "2026年6月 第1周" + final monthNames = [ + '', '1月', '2月', '3月', '4月', '5月', '6月', + '7月', '8月', '9月', '10月', '11月', '12月', + ]; + final title = + '${weekStart.year}年${monthNames[weekStart.month]} 第${_weekOfMonth(weekStart)}周'; + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 12, 0), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + fontFamily: AppTypography.displayFont, + fontWeight: FontWeight.w700, + ), + ), + ), + // 左右箭头导航按钮 + _NavButton( + icon: Icons.chevron_left_rounded, + onTap: onPrevious, + borderColor: colorScheme.outline, + foregroundColor: colorScheme.onSurface, + ), + const SizedBox(width: 8), + _NavButton( + icon: Icons.chevron_right_rounded, + onTap: onNext, + borderColor: colorScheme.outline, + foregroundColor: colorScheme.onSurface, + ), + ], + ), + ); + } + + /// 计算是当月第几周 + int _weekOfMonth(DateTime date) { + final firstDay = DateTime(date.year, date.month, 1); + final offset = firstDay.weekday - 1; + return ((date.day + offset) / 7).ceil(); + } +} + +/// 圆形导航按钮 (44px 触摸目标) +class _NavButton extends StatelessWidget { + const _NavButton({ + required this.icon, + required this.onTap, + required this.borderColor, + required this.foregroundColor, + }); + + final IconData icon; + final VoidCallback onTap; + final Color borderColor; + final Color foregroundColor; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 44, + height: 44, + child: OutlinedButton( + onPressed: onTap, + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + shape: const CircleBorder(), + side: BorderSide(color: borderColor, width: 1.5), + foregroundColor: foregroundColor, + backgroundColor: Theme.of(context).colorScheme.surface, + ), + child: Icon(icon, size: 18), + ), + ); + } +} + +// ===== 7天条目 ===== + +class _WeekStrip extends StatelessWidget { + const _WeekStrip({required this.weekStart}); + + final DateTime weekStart; + + // 模拟数据: 每天的心情 emoji + static const _mockMoods = ['😊', '😐', '😊', '😊', '😊', '😊', '😊']; + static const _weekNames = ['一', '二', '三', '四', '五', '六', '日']; + static const _hasEntry = [true, true, true, true, true, true, true]; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final now = DateTime.now(); + + return Row( + children: List.generate(7, (i) { + final day = weekStart.add(Duration(days: i)); + final isToday = day.year == now.year && + day.month == now.month && + day.day == now.day; + final hasEntry = _hasEntry[i]; + final moodEmoji = _mockMoods[i]; + + return Expanded( + child: GestureDetector( + onTap: () { + // TODO: 选择某天后刷新下方日记卡片 + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isToday ? AppColors.accent : null, + borderRadius: AppRadius.mdBorder, + ), + child: Column( + children: [ + // 周名 + Text( + _weekNames[i], + style: TextStyle( + fontSize: 11, + color: isToday + ? const Color(0xFFFFF8F0).withValues(alpha: 0.85) + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + // 日期数字 + Text( + '${day.day}', + style: TextStyle( + fontFamily: AppTypography.displayFont, + fontSize: 20, + fontWeight: FontWeight.w700, + color: isToday + ? const Color(0xFFFFF8F0) // accent-on + : colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 4), + // 心情 emoji + Text(moodEmoji, style: const TextStyle(fontSize: 16)), + // 有日记: 日期下方4px小圆点 + if (hasEntry && !isToday) + Container( + width: 4, + height: 4, + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.accent, + ), + ), + if (hasEntry && isToday) + Container( + width: 4, + height: 4, + margin: const EdgeInsets.only(top: 4), + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFFFF8F0), + ), + ), + ], + ), + ), + ), + ); + }), + ); + } +} + +// ===== 本周总结卡片 ===== + +class _WeekSummary extends StatelessWidget { + const _WeekSummary(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: AppRadius.mdBorder, + boxShadow: AppShadows.soft(context), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '本周总结', + style: theme.textTheme.titleMedium?.copyWith( + fontFamily: AppTypography.displayFont, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + // 3个统计数字 + Row( + children: [ + _SummaryItem( + value: '6', + label: '记录天数', + valueColor: AppColors.accent, + ), + _SummaryItem( + value: '7', + label: '日记篇数', + valueColor: AppColors.secondary, + ), + _SummaryItem( + value: '12', + label: '使用贴纸', + valueColor: AppColors.tertiary, + ), + ], + ), + const SizedBox(height: 16), + // 心情分布条 + Row( + children: [ + Expanded( + flex: 3, + child: Container( + height: 8, + decoration: BoxDecoration( + color: AppColors.secondary, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: Container( + height: 8, + decoration: BoxDecoration( + color: AppColors.tertiary, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 1, + child: Container( + height: 8, + decoration: BoxDecoration( + color: const Color(0xFF5B7DB1), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +/// 单个统计项 +class _SummaryItem extends StatelessWidget { + const _SummaryItem({ + required this.value, + required this.label, + required this.valueColor, + }); + + final String value; + final String label; + final Color valueColor; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + children: [ + Text( + value, + style: TextStyle( + fontFamily: AppTypography.displayFont, + fontSize: 24, + fontWeight: FontWeight.w700, + color: valueColor, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +// ===== 每日日记卡片 ===== + +class _DayCard extends StatelessWidget { + const _DayCard({ + required this.weekday, + required this.date, + required this.moodEmoji, + required this.weatherEmoji, + required this.body, + required this.tags, + this.photoEmoji, + }); + + final String weekday; + final String date; + final String moodEmoji; + final String weatherEmoji; + final String body; + final List<(String, Color, Color)> tags; // (label, bg, fg) + final String? photoEmoji; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: AppRadius.mdBorder, + boxShadow: AppShadows.soft(context), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 头部: 日期 + 心情/weather emoji + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$weekday · $date', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + Text( + '$moodEmoji $weatherEmoji', + style: const TextStyle(fontSize: 13), + ), + ], + ), + const SizedBox(height: 12), + // 正文预览 (3行截断) + Text( + body, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.6, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + // 标签 pills + if (tags.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 4, + children: tags.map((tag) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 3, + ), + decoration: BoxDecoration( + color: tag.$2, + borderRadius: AppRadius.pillBorder, + ), + child: Text( + tag.$1, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: tag.$3, + ), + ), + ); + }).toList(), + ), + ], + // 照片占位 + if (photoEmoji != null) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + height: 80, + decoration: BoxDecoration( + borderRadius: AppRadius.smBorder, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.surfaceContainerHighest + .withValues(alpha: 0.6), + colorScheme.outlineVariant.withValues(alpha: 0.3), + ], + ), + ), + alignment: Alignment.center, + child: Text(photoEmoji!, style: const TextStyle(fontSize: 24)), + ), + ], + ], + ), + ); + } +} diff --git a/app/lib/features/home/views/home_page.dart b/app/lib/features/home/views/home_page.dart index 87d455c..76a9830 100644 --- a/app/lib/features/home/views/home_page.dart +++ b/app/lib/features/home/views/home_page.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/core/theme/app_radius.dart'; +import 'package:nuanji_app/core/theme/app_typography.dart'; import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/repositories/journal_repository.dart'; import '../bloc/home_bloc.dart'; @@ -38,11 +40,17 @@ class _HomeView extends StatelessWidget { title: Text( '暖记', style: theme.textTheme.headlineSmall?.copyWith( - fontFamily: 'Caveat', + fontFamily: AppTypography.handwrittenFont, color: colorScheme.primary, ), ), actions: [ + // 搜索按钮(设计稿要求右上角圆形搜索按钮) + IconButton( + onPressed: () => context.push('/discover'), + icon: const Icon(Icons.search_rounded), + tooltip: '搜索', + ), IconButton( onPressed: () => context.go('/stickers'), icon: const Icon(Icons.emoji_emotions_outlined), @@ -136,7 +144,7 @@ class _QuickMoodCard extends StatelessWidget { return Card( elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), + shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder), color: colorScheme.primaryContainer.withValues(alpha: 0.3), child: Padding( padding: const EdgeInsets.all(20), @@ -152,7 +160,7 @@ class _QuickMoodCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppColors.tertiary.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), + borderRadius: AppRadius.xsBorder, ), child: Text('🔥 连续 $streakDays 天', style: theme.textTheme.labelSmall), ), @@ -161,7 +169,7 @@ class _QuickMoodCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppColors.secondary.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), + borderRadius: AppRadius.xsBorder, ), child: Text('✅ 今日已写', style: theme.textTheme.labelSmall), ), @@ -221,12 +229,12 @@ class _JournalList extends StatelessWidget { margin: const EdgeInsets.only(bottom: 12), elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, side: BorderSide(color: colorScheme.outlineVariant), ), child: InkWell( onTap: () => context.push('/editor?id=${journal.id}'), - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, child: Padding( padding: const EdgeInsets.all(16), child: Row( diff --git a/app/lib/features/mood/views/mood_page.dart b/app/lib/features/mood/views/mood_page.dart index 7b6a10d..8cb76d8 100644 --- a/app/lib/features/mood/views/mood_page.dart +++ b/app/lib/features/mood/views/mood_page.dart @@ -4,6 +4,7 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/data/models/journal_entry.dart'; import 'package:nuanji_app/data/remote/api_client.dart'; import '../bloc/mood_bloc.dart'; @@ -129,7 +130,7 @@ class _StatsOverviewCard extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), + borderRadius: AppRadius.lgBorder, ), color: colorScheme.primaryContainer, child: Padding( @@ -218,7 +219,7 @@ class _MoodDistributionChart extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), + borderRadius: AppRadius.lgBorder, side: BorderSide(color: colorScheme.outlineVariant), ), child: Padding( @@ -322,7 +323,7 @@ class _StreakCard extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), + borderRadius: AppRadius.lgBorder, ), color: AppColors.tertiary.withValues(alpha: 0.15), child: Padding( diff --git a/app/lib/features/onboarding/views/onboarding_page.dart b/app/lib/features/onboarding/views/onboarding_page.dart new file mode 100644 index 0000000..7b17fc7 --- /dev/null +++ b/app/lib/features/onboarding/views/onboarding_page.dart @@ -0,0 +1,495 @@ +// 引导页 — 3 页水平滑动引导 + 完成标记 +// 对齐 Open Design 原型稿 screens/onboarding.html +// +// 流程: +// Step 1: "用手账的方式记录每一天" — 笔记本+笔+贴纸 插图 +// Step 2: "海量贴纸与模板随心装饰" — 贴纸面板 插图 +// Step 3: "回顾成长轨迹看见自己的变化" — 心情日历 插图 +// +// 完成后: SharedPreferences 记录 onboarding_complete=true → 跳转 /login + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../core/constants/design_tokens.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_radius.dart'; +import '../../../core/theme/app_typography.dart'; + +/// 引导页 — 首次使用时展示功能介绍 +class OnboardingPage extends StatefulWidget { + const OnboardingPage({super.key}); + + @override + State createState() => _OnboardingPageState(); +} + +class _OnboardingPageState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + + static const int _totalPages = 3; + static const String _kOnboardingComplete = 'onboarding_complete'; + + static const _OnboardingStep _step1 = _OnboardingStep( + pageNumber: '01', + emoji: '📝', + titleLine1: '用手账的方式', + titleLine2: '记录每一天', + description: '文字、贴纸、涂鸦、照片 — 选择你喜欢的方式,把日常变成一本温暖的手账', + illustrationType: _IllustrationType.notebook, + ); + + static const _OnboardingStep _step2 = _OnboardingStep( + pageNumber: '02', + emoji: '🎨', + titleLine1: '海量贴纸与模板', + titleLine2: '随心装饰', + description: '数百款手绘贴纸、精美模板和装饰素材,让你的日记独一无二', + illustrationType: _IllustrationType.stickers, + ); + + static const _OnboardingStep _step3 = _OnboardingStep( + pageNumber: '03', + emoji: '🌱', + titleLine1: '回顾成长轨迹', + titleLine2: '看见自己的变化', + description: '心情追踪、日历回顾、统计洞察 — 不仅仅是记录,更是了解自己的旅程', + illustrationType: _IllustrationType.growth, + ); + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + Future _completeOnboarding() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kOnboardingComplete, true); + if (mounted) { + context.go('/login'); + } + } + + void _goToPage(int index) { + _pageController.animateToPage( + index, + duration: DesignTokens.animSlow, + curve: DesignTokens.warmCurve, + ); + } + + void _nextSlide() { + if (_currentPage < _totalPages - 1) { + _goToPage(_currentPage + 1); + } else { + _completeOnboarding(); + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return Scaffold( + body: Stack( + children: [ + // 页面内容 + PageView( + controller: _pageController, + onPageChanged: (index) { + setState(() => _currentPage = index); + }, + children: [ + _buildSlidePage(context, _step1, isDark), + _buildSlidePage(context, _step2, isDark), + _buildSlidePage(context, _step3, isDark), + ], + ), + + // 跳过按钮 — 右上角 + Positioned( + top: MediaQuery.of(context).padding.top + 8, + right: 20, + child: Semantics( + button: true, + label: '跳过引导', + child: GestureDetector( + onTap: _completeOnboarding, + behavior: HitTestBehavior.opaque, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + constraints: + const BoxConstraints(minWidth: 44, minHeight: 44), + alignment: Alignment.center, + child: Text( + '跳过', + style: TextStyle( + fontFamily: AppTypography.bodyFont, + fontSize: 15, + fontWeight: FontWeight.w500, + color: + isDark ? AppColors.mutedDark : AppColors.mutedLight, + ), + ), + ), + ), + ), + ), + + // 底部控制区 + Positioned( + left: 0, + right: 0, + bottom: bottomPadding + 24, + child: _buildBottomControls(isDark), + ), + ], + ), + ); + } + + /// 单页引导内容 + Widget _buildSlidePage( + BuildContext context, + _OnboardingStep step, + bool isDark, + ) { + final topPadding = MediaQuery.of(context).padding.top; + + return Padding( + padding: EdgeInsets.only( + top: topPadding + 48, + left: 32, + right: 32, + bottom: MediaQuery.of(context).padding.bottom + 120, + ), + child: Column( + children: [ + // 页码水印 + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 20), + child: Text( + step.pageNumber, + style: TextStyle( + fontFamily: AppTypography.displayFont, + fontSize: 120, + fontWeight: FontWeight.w800, + color: (isDark ? AppColors.fgDark : AppColors.fgLight) + .withValues(alpha: 0.04), + height: 1, + ), + ), + ), + ), + + const Spacer(flex: 1), + + // 插图 + _buildIllustration(step, isDark), + const SizedBox(height: 40), + + // 标题(两行) + Text( + '${step.titleLine1}\n${step.titleLine2}', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.displayFont, + fontSize: 24, + fontWeight: FontWeight.w700, + color: isDark ? AppColors.fgDark : AppColors.fgLight, + height: 1.3, + ), + ), + const SizedBox(height: 12), + + // 描述 + SizedBox( + width: 280, + child: Text( + step.description, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.bodyFont, + fontSize: 15, + fontWeight: FontWeight.w400, + color: + isDark ? AppColors.mutedDark : AppColors.mutedLight, + height: 1.6, + ), + ), + ), + + const Spacer(flex: 2), + ], + ), + ); + } + + /// 插图圆形区域 + Widget _buildIllustration(_OnboardingStep step, bool isDark) { + // 每个步骤不同的渐变背景 + final gradientColors = _getIllustrationGradient(step.illustrationType, isDark); + + return SizedBox( + width: 260, + height: 260, + child: Stack( + alignment: Alignment.center, + children: [ + // 外围虚线旋转圆 + _DashedCircleBorder( + size: 286, + color: isDark + ? AppColors.borderDark + : AppColors.borderLight, + ), + // 渐变圆形背景 + Container( + width: 260, + height: 260, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: gradientColors, + ), + ), + alignment: Alignment.center, + child: _buildIllustrationContent(step, isDark), + ), + ], + ), + ); + } + + List _getIllustrationGradient( + _IllustrationType type, + bool isDark, + ) { + switch (type) { + case _IllustrationType.notebook: + return [ + isDark ? AppColors.surfaceWarmDark : AppColors.surfaceWarmLight, + (isDark ? AppColors.accentDark : AppColors.accent) + .withValues(alpha: 0.3), + ]; + case _IllustrationType.stickers: + return [ + isDark ? AppColors.secondarySoftDark : AppColors.secondarySoftLight, + (isDark ? AppColors.secondaryDark : AppColors.secondary) + .withValues(alpha: 0.15), + ]; + case _IllustrationType.growth: + return [ + isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight, + (isDark ? AppColors.tertiaryDark : AppColors.tertiary) + .withValues(alpha: 0.2), + ]; + } + } + + /// 插图内容 — emoji 大图标 + Widget _buildIllustrationContent(_OnboardingStep step, bool isDark) { + return Text( + step.emoji, + style: const TextStyle(fontSize: 80), + ); + } + + /// 底部控制区 — 指示点 + 按钮 + Widget _buildBottomControls(bool isDark) { + final isLastPage = _currentPage == _totalPages - 1; + final accentColor = isDark ? AppColors.accentDark : AppColors.accent; + final accentOnColor = + isDark ? const Color(0xFF1A1614) : const Color(0xFFFFF8F0); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 指示点 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(_totalPages, (index) { + final isActive = index == _currentPage; + return AnimatedContainer( + duration: DesignTokens.animNormal, + curve: DesignTokens.warmCurve, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: isActive ? 28 : 8, + height: 8, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isActive + ? accentColor + : (isDark + ? AppColors.borderDark + : AppColors.borderLight), + ), + ); + }), + ), + const SizedBox(height: 24), + + // 下一步 / 开始使用 按钮 + GestureDetector( + onTap: _nextSlide, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: accentColor, + borderRadius: AppRadius.pillBorder, + boxShadow: [ + BoxShadow( + color: accentColor.withValues(alpha: 0.3), + offset: const Offset(0, 4), + blurRadius: 20, + ), + ], + ), + alignment: Alignment.center, + child: Text( + isLastPage ? '开始使用' : '下一步', + style: TextStyle( + fontFamily: AppTypography.displayFont, + fontSize: 20, + fontWeight: FontWeight.w600, + color: accentOnColor, + ), + ), + ), + ), + ], + ), + ); + } +} + +// ===== 数据模型 ===== + +/// 引导步骤数据 +class _OnboardingStep { + final String pageNumber; + final String emoji; + final String titleLine1; + final String titleLine2; + final String description; + final _IllustrationType illustrationType; + + const _OnboardingStep({ + required this.pageNumber, + required this.emoji, + required this.titleLine1, + required this.titleLine2, + required this.description, + required this.illustrationType, + }); +} + +/// 插图类型 +enum _IllustrationType { + notebook, + stickers, + growth, +} + +// ===== 虚线圆圈装饰 ===== + +/// 外围虚线旋转圆(对齐 onboarding.html 的 dashed border) +class _DashedCircleBorder extends StatefulWidget { + final double size; + final Color color; + + const _DashedCircleBorder({ + required this.size, + required this.color, + }); + + @override + State<_DashedCircleBorder> createState() => _DashedCircleBorderState(); +} + +class _DashedCircleBorderState extends State<_DashedCircleBorder> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 20), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.rotate( + angle: _controller.value * 6.2832, // 2 * PI + child: child, + ); + }, + child: CustomPaint( + size: Size(widget.size, widget.size), + painter: _DashedCirclePainter(color: widget.color), + ), + ); + } +} + +/// 虚线圆绘制器 +class _DashedCirclePainter extends CustomPainter { + final Color color; + + _DashedCirclePainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + final cx = size.width / 2; + final cy = size.height / 2; + final radius = size.width / 2 - 1; + + const dashCount = 36; + const dashAngle = 6.2832 / dashCount; // 2 * PI / dashCount + const dashLength = dashAngle * 0.6; + + for (var i = 0; i < dashCount; i++) { + final startAngle = i * dashAngle; + canvas.drawArc( + Rect.fromCircle(center: Offset(cx, cy), radius: radius), + startAngle, + dashLength, + false, + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _DashedCirclePainter oldDelegate) => + color != oldDelegate.color; +} diff --git a/app/lib/features/onboarding/views/splash_page.dart b/app/lib/features/onboarding/views/splash_page.dart new file mode 100644 index 0000000..db28029 --- /dev/null +++ b/app/lib/features/onboarding/views/splash_page.dart @@ -0,0 +1,694 @@ +// 启动页 — 全屏渐变背景 + App Logo + 自动跳转 +// 对齐 Open Design 原型稿 screens/splash.html +// +// 跳转逻辑: +// - 2 秒自动跳转或点击"开始记录"按钮 +// - 已认证 → /home +// - 首次使用(未完成引导) → /onboarding +// - 其他 → /login + +import 'dart:async'; +import 'dart:math' show cos, sin; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../core/constants/design_tokens.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_radius.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../auth/bloc/auth_bloc.dart'; + +/// 启动页 — App 打开后第一个画面 +class SplashPage extends StatefulWidget { + const SplashPage({super.key}); + + @override + State createState() => _SplashPageState(); +} + +class _SplashPageState extends State + with TickerProviderStateMixin { + late final AnimationController _scaleController; + late final Animation _scaleAnimation; + late final AnimationController _fadeController; + late final Animation _fadeAnimation; + + Timer? _autoNavigateTimer; + bool _hasNavigated = false; + + static const String _kOnboardingComplete = 'onboarding_complete'; + + @override + void initState() { + super.initState(); + + // 中心内容弹性缩放入场 + _scaleController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _scaleAnimation = CurvedAnimation( + parent: _scaleController, + curve: DesignTokens.warmCurve, + ); + + // 底部区域淡入(延迟 500ms) + _fadeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + ); + _fadeAnimation = CurvedAnimation( + parent: _fadeController, + curve: Curves.easeOut, + ); + + _scaleController.forward(); + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) _fadeController.forward(); + }); + + // 2 秒后自动跳转 + _autoNavigateTimer = Timer(const Duration(seconds: 2), _navigate); + } + + @override + void dispose() { + _autoNavigateTimer?.cancel(); + _scaleController.dispose(); + _fadeController.dispose(); + super.dispose(); + } + + Future _navigate() async { + if (_hasNavigated || !mounted) return; + _hasNavigated = true; + + final authState = context.read().state; + + // 已认证 → 直接进入首页 + if (authState is Authenticated) { + if (authState.needsRoleSelection) { + context.go('/role-selection'); + } else if (authState.needsClassCode) { + context.go('/class-code'); + } else { + context.go('/home'); + } + return; + } + + // 检查是否已完成引导 + final prefs = await SharedPreferences.getInstance(); + final onboardingComplete = prefs.getBool(_kOnboardingComplete) ?? false; + + if (!mounted) return; + + if (onboardingComplete) { + context.go('/login'); + } else { + context.go('/onboarding'); + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: const [0.0, 0.4, 1.0], + colors: [ + isDark ? AppColors.bgDark : AppColors.bgLight, + isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight, + isDark + ? AppColors.accentDark.withValues(alpha: 0.5) + : AppColors.accent.withValues(alpha: 0.5), + ], + ), + ), + child: Stack( + children: [ + // 浮动装饰元素 + _buildDecorations(isDark), + + // 中心内容 + Center( + child: ScaleTransition( + scale: _scaleAnimation, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildAppIcon(isDark), + const SizedBox(height: 28), + _buildAppName(isDark), + const SizedBox(height: 12), + _buildTagline(isDark), + ], + ), + ), + ), + + // 底部区域 + Positioned( + left: 0, + right: 0, + bottom: MediaQuery.of(context).padding.bottom + 40, + child: FadeTransition( + opacity: _fadeAnimation, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHandwrittenTagline(isDark), + const SizedBox(height: 20), + _buildEnterButton(isDark), + const SizedBox(height: 16), + _buildHint(isDark), + ], + ), + ), + ), + ], + ), + ), + ); + } + + /// 浮动装饰星星和圆圈 + Widget _buildDecorations(bool isDark) { + return Stack( + children: [ + // 星星 — 左上 + _FloatingStar( + top: 120, + left: 60, + size: 28, + color: isDark ? AppColors.accentDark : AppColors.accent, + animDelay: Duration.zero, + ), + // 星星 — 右上 + _FloatingStar( + top: 200, + right: 50, + size: 28, + color: isDark ? AppColors.secondaryDark : AppColors.secondary, + animDelay: const Duration(milliseconds: 1500), + ), + // 圆圈 — 右侧 + _FloatingCircle( + top: 160, + right: 80, + size: 80, + color: isDark ? AppColors.tertiaryDark : AppColors.tertiary, + animDelay: const Duration(milliseconds: 2000), + ), + // 圆圈 — 左下 + _FloatingCircle( + bottom: 300, + left: 40, + size: 50, + color: isDark ? AppColors.roseDark : AppColors.rose, + animDelay: const Duration(milliseconds: 3000), + ), + // 星星 — 右下 + _FloatingStar( + bottom: 350, + right: 70, + size: 28, + color: isDark ? AppColors.tertiaryDark : AppColors.tertiary, + animDelay: const Duration(milliseconds: 2500), + ), + ], + ); + } + + /// App 图标 — 120x120, 圆角 32px, accent→tertiary 渐变 + Widget _buildAppIcon(bool isDark) { + return Container( + width: 120, + height: 120, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + isDark ? AppColors.accentDark : AppColors.accent, + isDark ? AppColors.tertiaryDark : AppColors.tertiary, + ], + ), + boxShadow: [ + BoxShadow( + color: (isDark ? AppColors.accentDark : AppColors.accent) + .withValues(alpha: 0.3), + offset: const Offset(0, 8), + blurRadius: 32, + ), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + // 高光圆 + Positioned( + top: -20, + right: -20, + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.2), + ), + ), + ), + // 笔记本图标 + CustomPaint( + size: const Size(56, 56), + painter: _NotebookIconPainter( + accentColor: isDark ? AppColors.accentDark : AppColors.accent, + tertiaryColor: + isDark ? AppColors.tertiaryDark : AppColors.tertiary, + ), + ), + ], + ), + ); + } + + /// App 名 — "暖记" 42px/700, letter-spacing 2px + Widget _buildAppName(bool isDark) { + return Text( + '暖记', + style: TextStyle( + fontFamily: AppTypography.displayFont, + fontSize: 42, + fontWeight: FontWeight.w700, + color: isDark ? AppColors.fgDark : AppColors.fgLight, + letterSpacing: 2, + ), + ); + } + + /// Tagline — "用温暖的方式,记录每一天" + Widget _buildTagline(bool isDark) { + return Text( + '用温暖的方式,记录每一天', + style: TextStyle( + fontFamily: AppTypography.bodyFont, + fontSize: 17, + fontWeight: FontWeight.w400, + color: isDark ? AppColors.mutedDark : AppColors.mutedLight, + letterSpacing: 1, + ), + ); + } + + /// 手写 tagline — "每一页,都是你的故事" + Widget _buildHandwrittenTagline(bool isDark) { + return Text( + '每一页,都是你的故事', + style: TextStyle( + fontFamily: AppTypography.handwrittenFont, + fontSize: 22, + color: isDark ? AppColors.accentDark : AppColors.accent, + letterSpacing: 1, + ), + ); + } + + /// "开始记录" 按钮 — pill 形状, accent 背景色 + Widget _buildEnterButton(bool isDark) { + final accentColor = isDark ? AppColors.accentDark : AppColors.accent; + final accentOnColor = + isDark ? const Color(0xFF1A1614) : const Color(0xFFFFF8F0); + + return SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: GestureDetector( + onTap: _navigate, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16), + decoration: BoxDecoration( + color: accentColor, + borderRadius: AppRadius.pillBorder, + boxShadow: [ + BoxShadow( + color: accentColor.withValues(alpha: 0.3), + offset: const Offset(0, 4), + blurRadius: 20, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '开始记录', + style: TextStyle( + fontFamily: AppTypography.displayFont, + fontSize: 20, + fontWeight: FontWeight.w600, + color: accentOnColor, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward_rounded, + size: 20, + color: accentOnColor, + ), + ], + ), + ), + ), + ), + ); + } + + /// 底部提示 + Widget _buildHint(bool isDark) { + return Text( + '每一天都值得被温柔记录', + style: TextStyle( + fontFamily: AppTypography.bodyFont, + fontSize: 13, + fontWeight: FontWeight.w400, + color: isDark ? AppColors.metaDark : AppColors.metaLight, + ), + ); + } +} + +// ===== 浮动装饰组件 ===== + +/// 浮动星星装饰 +class _FloatingStar extends StatefulWidget { + final double? top; + final double? bottom; + final double? left; + final double? right; + final double size; + final Color color; + final Duration animDelay; + + const _FloatingStar({ + this.top, + this.bottom, + this.left, + this.right, + required this.size, + required this.color, + required this.animDelay, + }); + + @override + State<_FloatingStar> createState() => _FloatingStarState(); +} + +class _FloatingStarState extends State<_FloatingStar> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 4), + ); + _animation = Tween(begin: 0.0, end: 1.0).animate(_controller); + + Future.delayed(widget.animDelay, () { + if (mounted) _controller.repeat(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Positioned( + top: widget.top, + bottom: widget.bottom, + left: widget.left, + right: widget.right, + child: AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final offset = + _animation.value < 0.5 ? _animation.value * -10 : (_animation.value - 0.5) * -10 + 10; + final rotation = _animation.value * 0.14; // ~5deg + return Transform.translate( + offset: Offset(0, -offset + 5), + child: Transform.rotate( + angle: rotation, + child: child, + ), + ); + }, + child: Opacity( + opacity: 0.25, + child: CustomPaint( + size: Size(widget.size, widget.size), + painter: _StarPainter(color: widget.color), + ), + ), + ), + ); + } +} + +/// 浮动圆形装饰 +class _FloatingCircle extends StatefulWidget { + final double? top; + final double? bottom; + final double? left; + final double? right; + final double size; + final Color color; + final Duration animDelay; + + const _FloatingCircle({ + this.top, + this.bottom, + this.left, + this.right, + required this.size, + required this.color, + required this.animDelay, + }); + + @override + State<_FloatingCircle> createState() => _FloatingCircleState(); +} + +class _FloatingCircleState extends State<_FloatingCircle> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 5), + ); + _animation = Tween(begin: 0.0, end: 1.0).animate(_controller); + + Future.delayed(widget.animDelay, () { + if (mounted) _controller.repeat(reverse: true); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Positioned( + top: widget.top, + bottom: widget.bottom, + left: widget.left, + right: widget.right, + child: AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final offset = _animation.value * -10; + return Transform.translate( + offset: Offset(0, offset), + child: child, + ); + }, + child: Opacity( + opacity: 0.12, + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.color, + ), + ), + ), + ), + ); + } +} + +// ===== CustomPainter — 笔记本图标 ===== + +/// 笔记本图标绘制器(对齐 splash.html 中的 SVG) +class _NotebookIconPainter extends CustomPainter { + final Color accentColor; + final Color tertiaryColor; + + _NotebookIconPainter({ + required this.accentColor, + required this.tertiaryColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final s = size.width / 56; // 基准 56px 缩放 + + // 白色笔记本主体 + final bodyPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.9) + ..style = PaintingStyle.fill; + final bodyRect = RRect.fromRectAndRadius( + Rect.fromLTWH(10 * s, 6 * s, 36 * s, 44 * s), + Radius.circular(4 * s), + ); + canvas.drawRRect(bodyRect, bodyPaint); + + // 脊部装饰线 + final spinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.3) + ..style = PaintingStyle.fill; + canvas.drawRect( + Rect.fromLTWH(14 * s, 6 * s, 4 * s, 44 * s), + spinePaint, + ); + + // 文字横线 + final linePaint = Paint() + ..color = accentColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2 * s + ..strokeCap = StrokeCap.round; + + canvas.drawLine( + Offset(20 * s, 18 * s), + Offset(38 * s, 18 * s), + linePaint, + ); + canvas.drawLine( + Offset(20 * s, 24 * s), + Offset(34 * s, 24 * s), + linePaint, + ); + canvas.drawLine( + Offset(20 * s, 30 * s), + Offset(38 * s, 30 * s), + linePaint, + ); + canvas.drawLine( + Offset(20 * s, 36 * s), + Offset(30 * s, 36 * s), + linePaint, + ); + + // 右上角勾选标记圆形 + final checkCirclePaint = Paint() + ..color = tertiaryColor.withValues(alpha: 0.6) + ..style = PaintingStyle.fill; + canvas.drawCircle( + Offset(42 * s, 14 * s), + 8 * s, + checkCirclePaint, + ); + + // 勾选符号 + final checkPaint = Paint() + ..color = accentColor + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 * s + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final checkPath = Path() + ..moveTo(39 * s, 14 * s) + ..lineTo(41 * s, 16 * s) + ..lineTo(45 * s, 12 * s); + canvas.drawPath(checkPath, checkPaint); + } + + @override + bool shouldRepaint(covariant _NotebookIconPainter oldDelegate) => + accentColor != oldDelegate.accentColor || + tertiaryColor != oldDelegate.tertiaryColor; +} + +// ===== CustomPainter — 五角星 ===== + +/// 五角星绘制器(装饰元素) +class _StarPainter extends CustomPainter { + final Color color; + + _StarPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final path = Path(); + final cx = size.width / 2; + final cy = size.height / 2; + final outerRadius = size.width / 2; + final innerRadius = outerRadius * 0.4; + + for (var i = 0; i < 5; i++) { + final outerAngle = (i * 72 - 90) * 3.14159 / 180; + final innerAngle = ((i * 72) + 36 - 90) * 3.14159 / 180; + + final ox = cx + outerRadius * cos(outerAngle); + final oy = cy + outerRadius * sin(outerAngle); + final ix = cx + innerRadius * cos(innerAngle); + final iy = cy + innerRadius * sin(innerAngle); + + if (i == 0) { + path.moveTo(ox, oy); + } else { + path.lineTo(ox, oy); + } + path.lineTo(ix, iy); + } + path.close(); + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _StarPainter oldDelegate) => + color != oldDelegate.color; +} diff --git a/app/lib/features/profile/views/profile_page.dart b/app/lib/features/profile/views/profile_page.dart index 00d8d66..3f7bf96 100644 --- a/app/lib/features/profile/views/profile_page.dart +++ b/app/lib/features/profile/views/profile_page.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart'; +import 'package:nuanji_app/data/models/user.dart'; /// 个人中心页面 class ProfilePage extends StatelessWidget { @@ -25,7 +27,7 @@ class ProfilePage extends StatelessWidget { // 用户头像卡片 Card( elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), + shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder), color: colorScheme.primaryContainer, child: Padding( padding: const EdgeInsets.all(24), @@ -132,9 +134,9 @@ class ProfilePage extends StatelessWidget { ); } - String _roleLabel(dynamic role) { + String _roleLabel(UserRoleType? role) { if (role == null) return '未选择角色'; - return switch (role.name) { + return switch (role.code) { 'teacher' => '老师', 'student' => '学生', 'parent' => '家长', diff --git a/app/lib/features/settings/views/settings_page.dart b/app/lib/features/settings/views/settings_page.dart index 18c7c63..b562ff3 100644 --- a/app/lib/features/settings/views/settings_page.dart +++ b/app/lib/features/settings/views/settings_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/features/profile/bloc/settings_bloc.dart'; /// 设置页面 @@ -92,7 +93,7 @@ class _ThemeSelectorCard extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), + borderRadius: AppRadius.lgBorder, side: BorderSide(color: colorScheme.outlineVariant), ), child: Padding( @@ -165,7 +166,7 @@ class _AboutCard extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), + borderRadius: AppRadius.lgBorder, side: BorderSide(color: colorScheme.outlineVariant), ), child: Column( @@ -180,7 +181,7 @@ class _AboutCard extends StatelessWidget { height: 56, decoration: BoxDecoration( color: AppColors.accent.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, ), alignment: Alignment.center, child: const Text('📝', style: TextStyle(fontSize: 28)), @@ -294,7 +295,7 @@ class _LegalCard extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), + borderRadius: AppRadius.lgBorder, side: BorderSide(color: colorScheme.outlineVariant), ), child: Column( diff --git a/app/lib/features/stickers/views/sticker_library_page.dart b/app/lib/features/stickers/views/sticker_library_page.dart index 5910213..c3e4214 100644 --- a/app/lib/features/stickers/views/sticker_library_page.dart +++ b/app/lib/features/stickers/views/sticker_library_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/data/remote/api_client.dart'; import '../bloc/sticker_bloc.dart'; @@ -135,7 +136,7 @@ class _StickerPackCard extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, side: BorderSide(color: colorScheme.outlineVariant), ), child: InkWell( @@ -144,7 +145,7 @@ class _StickerPackCard extends StatelessWidget { SnackBar(content: Text('打开贴纸包: ${pack.name}')), ); }, - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -156,7 +157,7 @@ class _StickerPackCard extends StatelessWidget { height: 64, decoration: BoxDecoration( color: colorScheme.primaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, ), alignment: Alignment.center, child: Text( diff --git a/app/lib/features/teacher/views/teacher_page.dart b/app/lib/features/teacher/views/teacher_page.dart index f1e4ae4..acf0833 100644 --- a/app/lib/features/teacher/views/teacher_page.dart +++ b/app/lib/features/teacher/views/teacher_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/data/repositories/class_repository.dart'; import 'package:nuanji_app/data/repositories/journal_repository.dart'; import '../../class_/bloc/class_bloc.dart'; @@ -241,12 +242,12 @@ class _ActionCard extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, side: BorderSide(color: colorScheme.outlineVariant), ), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, child: Padding( padding: const EdgeInsets.all(16), child: Row( diff --git a/app/lib/features/templates/views/template_gallery_page.dart b/app/lib/features/templates/views/template_gallery_page.dart index efe319a..93cbcd5 100644 --- a/app/lib/features/templates/views/template_gallery_page.dart +++ b/app/lib/features/templates/views/template_gallery_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/core/theme/app_radius.dart'; import 'package:nuanji_app/data/remote/api_client.dart'; import '../bloc/template_bloc.dart'; @@ -133,7 +134,7 @@ class _TemplateCard extends StatelessWidget { return Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, side: BorderSide(color: colorScheme.outlineVariant), ), child: InkWell( @@ -141,7 +142,7 @@ class _TemplateCard extends StatelessWidget { // 使用模板创建日记 context.push('/editor?template=${template.id}'); }, - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -160,7 +161,7 @@ class _TemplateCard extends StatelessWidget { AppColors.tertiary.withValues(alpha: 0.3), ], ), - borderRadius: BorderRadius.circular(16), + borderRadius: AppRadius.mdBorder, ), alignment: Alignment.center, child: Text( diff --git a/app/lib/widgets/responsive_scaffold.dart b/app/lib/widgets/responsive_scaffold.dart index 8fadc08..b85c3ee 100644 --- a/app/lib/widgets/responsive_scaffold.dart +++ b/app/lib/widgets/responsive_scaffold.dart @@ -1,10 +1,17 @@ // 暖记响应式骨架 — 三级自适应布局 -// 手机: 底部 TabBar | 平板: 侧边导航 | 桌面: 三栏 +// 手机: 底部 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 { @@ -13,15 +20,23 @@ class ResponsiveScaffold extends StatefulWidget { required this.selectedIndex, required this.onDestinationSelected, required this.body, - this.floatingActionButton, + this.onCenterButtonPressed, this.appBarTitle, this.secondaryBody, }); + /// 当前选中的导航索引 (0-3,对应首页/日历/发现/我的) final int selectedIndex; + + /// 导航项被选中回调 final ValueChanged onDestinationSelected; + + /// 主内容区 final Widget body; - final Widget? floatingActionButton; + + /// 中心写日记按钮回调 + final VoidCallback? onCenterButtonPressed; + final String? appBarTitle; final Widget? secondaryBody; @@ -34,7 +49,6 @@ class _ResponsiveScaffoldState extends State { Widget build(BuildContext context) { final width = MediaQuery.sizeOf(context).width; // Web 平台:始终使用移动端底部 TabBar 布局 - // 原因:Web 上 NavigationRail 点击事件可能被 Flutter CanvasKit 拦截 final deviceType = kIsWeb ? DeviceType.mobile : Breakpoints.getDeviceType(width); @@ -45,7 +59,7 @@ class _ResponsiveScaffoldState extends State { selectedIndex: widget.selectedIndex, onDestinationSelected: widget.onDestinationSelected, body: widget.body, - floatingActionButton: widget.floatingActionButton, + onCenterButtonPressed: widget.onCenterButtonPressed, appBarTitle: widget.appBarTitle, ); case DeviceType.tablet: @@ -53,6 +67,7 @@ class _ResponsiveScaffoldState extends State { selectedIndex: widget.selectedIndex, onDestinationSelected: widget.onDestinationSelected, body: widget.body, + onCenterButtonPressed: widget.onCenterButtonPressed, appBarTitle: widget.appBarTitle, ); case DeviceType.desktop: @@ -60,6 +75,7 @@ class _ResponsiveScaffoldState extends State { selectedIndex: widget.selectedIndex, onDestinationSelected: widget.onDestinationSelected, body: widget.body, + onCenterButtonPressed: widget.onCenterButtonPressed, secondaryBody: widget.secondaryBody, appBarTitle: widget.appBarTitle, ); @@ -67,32 +83,28 @@ class _ResponsiveScaffoldState extends State { } } -// ===== 导航项定义 ===== +// ===== 导航项定义(4 项,中间由 FAB 占位)===== -final _navItems = [ +const _navItems = [ NavigationDestination( - icon: const Icon(Icons.home_outlined), - selectedIcon: const Icon(Icons.home), + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), label: '首页', ), NavigationDestination( - icon: const Icon(Icons.calendar_month_outlined), - selectedIcon: const Icon(Icons.calendar_month), + icon: Icon(Icons.calendar_month_outlined), + selectedIcon: Icon(Icons.calendar_month), label: '日历', ), + // 索引 2: 中心 FAB 占位(写日记) NavigationDestination( - icon: const Icon(Icons.auto_awesome_outlined), - selectedIcon: const Icon(Icons.auto_awesome), - label: '心情', + icon: Icon(Icons.explore_outlined), + selectedIcon: Icon(Icons.explore), + label: '发现', ), NavigationDestination( - icon: const Icon(Icons.search_outlined), - selectedIcon: const Icon(Icons.search), - label: '搜索', - ), - NavigationDestination( - icon: const Icon(Icons.person_outline), - selectedIcon: const Icon(Icons.person), + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), label: '我的', ), ]; @@ -109,14 +121,9 @@ const _railItems = [ label: Text('日历'), ), NavigationRailDestination( - icon: Icon(Icons.auto_awesome_outlined), - selectedIcon: Icon(Icons.auto_awesome), - label: Text('心情'), - ), - NavigationRailDestination( - icon: Icon(Icons.search_outlined), - selectedIcon: Icon(Icons.search), - label: Text('搜索'), + icon: Icon(Icons.explore_outlined), + selectedIcon: Icon(Icons.explore), + label: Text('发现'), ), NavigationRailDestination( icon: Icon(Icons.person_outline), @@ -125,21 +132,42 @@ const _railItems = [ ), ]; -// ===== 手机布局 — 底部 TabBar ===== +// ===== 中心写日记 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.floatingActionButton, + this.onCenterButtonPressed, this.appBarTitle, }); final int selectedIndex; final ValueChanged onDestinationSelected; final Widget body; - final Widget? floatingActionButton; + final VoidCallback? onCenterButtonPressed; final String? appBarTitle; @override @@ -149,11 +177,138 @@ class _MobileLayout extends StatelessWidget { ? AppBar(title: Text(appBarTitle!)) : null, body: body, - floatingActionButton: floatingActionButton, - bottomNavigationBar: NavigationBar( + floatingActionButton: onCenterButtonPressed != null + ? _CenterFabButton(onPressed: onCenterButtonPressed!) + : null, + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + bottomNavigationBar: _BottomNavBar( selectedIndex: selectedIndex, onDestinationSelected: onDestinationSelected, - destinations: _navItems, + ), + ); + } +} + +/// 自定义底部导航栏 — 支持中心凹槽 +class _BottomNavBar extends StatelessWidget { + const _BottomNavBar({ + required this.selectedIndex, + required this.onDestinationSelected, + }); + + final int selectedIndex; + final ValueChanged 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, + ), + ), + ], + ), ), ); } @@ -166,16 +321,20 @@ class _TabletLayout extends StatelessWidget { required this.selectedIndex, required this.onDestinationSelected, required this.body, + this.onCenterButtonPressed, this.appBarTitle, }); final int selectedIndex; final ValueChanged 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!)) @@ -187,11 +346,25 @@ class _TabletLayout extends StatelessWidget { onDestinationSelected: onDestinationSelected, destinations: _railItems, leading: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Icon( - Icons.edit_note_rounded, - size: 32, - color: Theme.of(context).colorScheme.primary, + 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), + ), + ], ), ), ), @@ -210,6 +383,7 @@ class _DesktopLayout extends StatelessWidget { required this.selectedIndex, required this.onDestinationSelected, required this.body, + this.onCenterButtonPressed, this.secondaryBody, this.appBarTitle, }); @@ -217,11 +391,14 @@ class _DesktopLayout extends StatelessWidget { final int selectedIndex; final ValueChanged 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!)) @@ -234,23 +411,45 @@ class _DesktopLayout extends StatelessWidget { destinations: _railItems, extended: true, leading: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Row( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.edit_note_rounded, - size: 32, - color: Theme.of(context).colorScheme.primary, + 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(width: 12), - Text( - '暖记', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontFamily: 'Caveat', - color: Theme.of(context).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, + ), + ), + ), ), - ), ], ), ), diff --git a/config/default.toml b/config/default.toml index 84dda02..e7352c4 100644 --- a/config/default.toml +++ b/config/default.toml @@ -23,7 +23,7 @@ level = "info" [cors] # Comma-separated allowed origins. Use "*" for development only. -allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000" +allowed_origins = "*" [wechat] appid = "__MUST_SET_VIA_ENV__" diff --git a/crates/erp-diary/src/service/class_service.rs b/crates/erp-diary/src/service/class_service.rs index 5658160..05600e0 100644 --- a/crates/erp-diary/src/service/class_service.rs +++ b/crates/erp-diary/src/service/class_service.rs @@ -341,11 +341,17 @@ impl ClassService { /// 生成 6 位班级码(UUID 前 6 位字符) fn generate_class_code() -> String { + // UUID v7 毫秒级时间戳前缀在紧凑循环中可能重复 + // 取后 6 位(随机部分)而非前 6 位(时间戳部分) Uuid::now_v7() .to_string() .replace("-", "") .chars() + .rev() .take(6) + .collect::() + .chars() + .rev() .collect() } diff --git a/crates/erp-server/migration/src/m20260601_000300_diary_role_seed.rs b/crates/erp-server/migration/src/m20260601_000300_diary_role_seed.rs index 8c4acdf..2e764d3 100644 --- a/crates/erp-server/migration/src/m20260601_000300_diary_role_seed.rs +++ b/crates/erp-server/migration/src/m20260601_000300_diary_role_seed.rs @@ -49,14 +49,15 @@ impl MigrationTrait for Migration { ]; for (role_code, perm_code) in &role_permissions { + // role_permissions 表主键为 (role_id, permission_id),无 id 列 let sql = format!( - r#"INSERT INTO role_permissions (id, role_id, permission_id, tenant_id, created_at, updated_at, created_by, updated_by, version) - SELECT gen_random_uuid(), r.id, p.id, r.tenant_id, now(), now(), {tid}, {tid}, 1 + r#"INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_at, updated_at, created_by, updated_by, version) + SELECT r.id, p.id, r.tenant_id, now(), now(), {tid}, {tid}, 1 FROM roles r JOIN permissions p ON p.tenant_id = r.tenant_id WHERE r.code = '{role_code}' AND r.tenant_id = {tid} AND r.deleted_at IS NULL AND p.code = '{perm_code}' AND p.deleted_at IS NULL - ON CONFLICT DO NOTHING"#, + ON CONFLICT (role_id, permission_id) DO NOTHING"#, ); conn.execute(sea_orm::Statement::from_string( sea_orm::DatabaseBackend::Postgres, diff --git a/docs/opendesign/css/components.css b/docs/opendesign/css/components.css new file mode 100644 index 0000000..ff2ad49 --- /dev/null +++ b/docs/opendesign/css/components.css @@ -0,0 +1,296 @@ +/* ───────────────────────────────────────────────────────── + * 暖记 — 共享组件样式 + * ───────────────────────────────────────────────────────── */ + +/* Reset & base */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +body { + font-family: var(--font-body); + font-size: var(--text-base); + line-height: var(--leading-body); + color: var(--fg); + background: var(--bg); + overflow-x: hidden; +} + +/* Phone frame wrapper */ +.phone-frame { + width: 390px; + height: 844px; + position: relative; + overflow: hidden; + background: var(--bg); + border-radius: 44px; + box-shadow: var(--elev-float); +} + +/* Status bar */ +.status-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 28px 0; + height: var(--safe-top); + font-size: var(--text-sm); + font-weight: 600; + color: var(--fg); + position: relative; + z-index: 100; +} +.status-bar .time { font-variant-numeric: tabular-nums; } +.status-bar .icons { display: flex; gap: 6px; align-items: center; } +.status-bar .icons svg { width: 18px; height: 18px; } + +/* Dynamic Island */ +.dynamic-island { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + width: 126px; + height: 36px; + background: #000; + border-radius: 20px; + z-index: 200; +} + +/* Top nav bar */ +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-5); + position: relative; + z-index: 10; +} +.top-nav .title { + font-family: var(--font-display); + font-size: var(--text-xl); + font-weight: 700; + color: var(--fg); +} +.top-nav .action { + width: var(--touch-min); + height: var(--touch-min); + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-pill); + background: var(--surface); + border: none; + cursor: pointer; + transition: background var(--motion-fast) var(--ease-standard); +} +.top-nav .action:hover { background: var(--border-soft); } +.top-nav .action:focus-visible { box-shadow: var(--focus-ring); outline: none; } +.top-nav .action svg { width: 22px; height: 22px; color: var(--fg); } + +/* Bottom tab bar */ +.tab-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: calc(var(--tab-height) + var(--safe-bottom)); + padding-bottom: var(--safe-bottom); + display: flex; + align-items: flex-start; + justify-content: space-around; + background: var(--surface); + border-top: 1px solid var(--border-soft); + z-index: 100; +} +.tab-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding-top: 10px; + background: none; + border: none; + cursor: pointer; + color: var(--muted); + font-size: var(--text-xs); + font-weight: 500; + transition: color var(--motion-fast) var(--ease-standard); +} +.tab-item.active { color: var(--accent); } +.tab-item svg { width: 24px; height: 24px; } +.tab-item span { margin-top: 2px; } + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: 14px 28px; + border-radius: var(--radius-pill); + border: none; + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 600; + cursor: pointer; + transition: all var(--motion-fast) var(--ease-standard); +} +.btn:active { transform: scale(0.97); } +.btn-primary { + background: var(--accent); + color: var(--accent-on); +} +.btn-primary:hover { background: var(--accent-hover); } +.btn-primary:focus-visible { box-shadow: var(--focus-ring); outline: none; } +.btn-secondary { + background: var(--surface); + color: var(--fg); + border: 1.5px solid var(--border); +} +.btn-secondary:hover { border-color: var(--accent); color: var(--accent); } +.btn-secondary:focus-visible { box-shadow: var(--focus-ring); outline: none; } +.btn-ghost { + background: transparent; + color: var(--muted); +} +.btn-ghost:hover { color: var(--accent); } + +/* Cards */ +.card { + background: var(--surface); + border-radius: var(--radius-md); + padding: var(--space-5); + box-shadow: var(--elev-soft); + border: 1px solid var(--border-soft); +} + +/* Tags / chips */ +.chip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 6px 14px; + border-radius: var(--radius-pill); + font-size: var(--text-sm); + font-weight: 500; + background: var(--surface); + color: var(--fg-2); + border: 1px solid var(--border); + transition: all var(--motion-fast) var(--ease-standard); +} +.chip:focus-visible { box-shadow: var(--focus-ring); outline: none; } +.chip.active { + background: var(--accent); + color: var(--accent-on); + border-color: var(--accent); +} + +/* Section heading */ +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-4); +} +.section-heading h3 { + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 700; +} +.section-heading .more { + font-size: var(--text-sm); + color: var(--accent); + cursor: pointer; + background: none; + border: none; + font-weight: 500; + min-height: var(--touch-min); + display: inline-flex; + align-items: center; +} +.section-heading .more:focus-visible { box-shadow: var(--focus-ring); outline: none; border-radius: 4px; } + +/* Mood emoji faces */ +.mood-row { + display: flex; + gap: var(--space-3); + justify-content: center; +} +.mood-btn { + width: 48px; + height: 48px; + border-radius: 50%; + border: 2px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + background: var(--surface); + cursor: pointer; + transition: all var(--motion-fast) var(--ease-bounce); +} +.mood-btn:hover { transform: scale(1.1); } +.mood-btn:focus-visible { box-shadow: var(--focus-ring); outline: none; } +.mood-btn.selected { + border-color: var(--accent); + background: var(--surface-warm); + transform: scale(1.08); +} + +/* Scroll content area */ +.scroll-content { + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +.scroll-content::-webkit-scrollbar { display: none; } + +/* Utility */ +.text-center { text-align: center; } +.text-muted { color: var(--muted); } +.text-accent { color: var(--accent); } +.text-secondary { color: var(--secondary); } +.font-display { font-family: var(--font-display); } +.font-hand { font-family: var(--font-handwritten); } +.flex { display: flex; } +.flex-col { flex-direction: column; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.justify-center { justify-content: center; } +.gap-2 { gap: var(--space-2); } +.gap-3 { gap: var(--space-3); } +.gap-4 { gap: var(--space-4); } +.gap-5 { gap: var(--space-5); } +.gap-6 { gap: var(--space-6); } +.w-full { width: 100%; } +.rounded-full { border-radius: var(--radius-pill); } + +/* Decorative doodles */ +.doodle { opacity: 0.15; pointer-events: none; } + +/* Home indicator */ +.home-indicator { + width: 134px; + height: 5px; + background: var(--fg); + opacity: 0.2; + border-radius: 3px; + margin: 8px auto; +} + +/* Page transitions */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes slideUp { + from { opacity: 0; transform: translateY(40px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} +.anim-fade { animation: fadeIn 0.5s var(--ease-standard) both; } +.anim-slide { animation: slideUp 0.6s var(--ease-bounce) both; } +.anim-scale { animation: scaleIn 0.4s var(--ease-bounce) both; } diff --git a/docs/opendesign/css/editor-common.css b/docs/opendesign/css/editor-common.css new file mode 100644 index 0000000..425eb9c --- /dev/null +++ b/docs/opendesign/css/editor-common.css @@ -0,0 +1,502 @@ +/* ───────────────────────────────────────────────────────── + * 暖记 — 编辑器共享样式 + * 三端通用 (mobile / tablet / desktop) + * ───────────────────────────────────────────────────────── */ + +/* Top toolbar */ +.editor-topbar { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--surface); + border-bottom: 1px solid var(--border-soft); +} + +.topbar-left, .topbar-right { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.topbar-btn { + border-radius: var(--radius-pill); + border: none; + background: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--fg); + transition: background var(--motion-fast); +} + +.topbar-btn:hover { background: var(--surface-warm); } + +.topbar-center { + font-family: var(--font-display); + font-size: var(--text-base); + font-weight: 600; + color: var(--fg-2); +} + +/* Save button */ +.save-btn { + background: var(--accent); + color: var(--accent-on); + border: none; + border-radius: var(--radius-pill); + font-family: var(--font-display); + font-size: var(--text-sm); + font-weight: 600; + cursor: pointer; + transition: all var(--motion-fast); +} + +.save-btn:hover { background: var(--accent-hover); } + +/* Canvas area common */ +.canvas-grid { + position: absolute; + inset: 0; + background-image: radial-gradient(circle, var(--border) 1px, transparent 1px); + background-size: 28px 28px; + opacity: 0.4; + pointer-events: none; + border-radius: var(--radius-md); +} + +.washi-tape { + position: absolute; + background: repeating-linear-gradient( + 45deg, + rgba(242, 204, 143, 0.7), + rgba(242, 204, 143, 0.7) 4px, + rgba(255, 243, 230, 0.7) 4px, + rgba(255, 243, 230, 0.7) 8px + ); + transform: rotate(-8deg); + border-radius: 2px; + opacity: 0.8; +} + +.placed-sticker { + position: absolute; + cursor: move; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); +} + +.journal-content { position: relative; z-index: 1; } + +.journal-title { + font-family: var(--font-display); + font-size: var(--text-2xl); + font-weight: 700; + color: var(--fg); + margin-bottom: var(--space-4); + border: none; + outline: none; + width: 100%; + background: transparent; +} + +.journal-body { + font-size: var(--text-base); + line-height: 1.9; + color: var(--fg-2); + border: none; + outline: none; + width: 100%; + background: transparent; + resize: none; + min-height: 240px; +} + +/* Format bar */ +.format-bar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) 0; + margin-bottom: var(--space-4); + border-bottom: 1px solid var(--border-soft); +} + +.format-btn { + border-radius: 6px; + border: none; + background: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--muted); + font-size: var(--text-sm); + font-weight: 600; + transition: all var(--motion-fast); +} + +.format-btn:hover { background: var(--surface-warm); color: var(--fg); } +.format-btn.active { background: var(--accent); color: #FFF8F0; } + +.format-divider { + width: 1px; + height: 22px; + background: var(--border); +} + +/* Color dots */ +.color-dot { + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: all var(--motion-fast); +} + +.color-dot:hover { transform: scale(1.15); } +.color-dot.active { border-color: var(--fg); } + +/* Placed photo in journal */ +.placed-photo { + width: 100%; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, #D4E8DC, #E8F4ED); + display: flex; + align-items: center; + justify-content: center; +} + +/* Panel tabs */ +.panel-tab { + padding: 8px 16px; + border-radius: var(--radius-pill); + font-size: var(--text-sm); + font-weight: 500; + background: var(--surface-warm); + color: var(--fg-2); + border: none; + cursor: pointer; + white-space: nowrap; + transition: all var(--motion-fast); +} + +.panel-tab.active { background: var(--accent); color: #FFF8F0; } + +.panel-content { + padding: var(--space-5); +} + +/* Sticker grid */ +.sticker-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: var(--space-3); +} + +.sticker-item { + aspect-ratio: 1; + border-radius: var(--radius-sm); + background: var(--surface-warm); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + cursor: pointer; + transition: all var(--motion-fast) var(--ease-bounce); + border: 1px solid transparent; +} + +.sticker-item:hover { transform: scale(1.1); border-color: var(--accent); } + +/* Date strip */ +.date-strip { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--surface); +} + +.date-info { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--muted); +} + +.date-info strong { color: var(--fg); font-weight: 600; } + +.mood-quick { display: flex; gap: 6px; } + +.mood-mini { + border-radius: 50%; + border: 1.5px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + background: var(--surface); + cursor: pointer; + transition: all var(--motion-fast); +} + +.mood-mini.selected { border-color: var(--accent); background: var(--surface-warm); } + +/* Undo/Redo */ +.topbar-undo-redo { + display: flex; + align-items: center; + gap: 2px; +} + +.undo-btn, .redo-btn { + border-radius: var(--radius-pill); + border: none; + background: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--muted); + transition: all var(--motion-fast); +} + +.undo-btn:hover:not(:disabled), .redo-btn:hover:not(:disabled) { background: var(--surface-warm); color: var(--fg); } +.undo-btn:disabled, .redo-btn:disabled { opacity: 0.35; cursor: default; } +.undo-btn svg, .redo-btn svg { width: 18px; height: 18px; } + +/* Auto-save indicator */ +.autosave-status { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--success); + font-weight: 500; +} + +.autosave-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); +} + +/* Brush slider (size + opacity) */ +.brush-slider { + flex: 1; + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 2px; + background: var(--border); + outline: none; +} + +.brush-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); +} + +.brush-size-row { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.brush-size-value { + font-size: var(--text-sm); + font-weight: 600; + color: var(--fg); + min-width: 36px; + text-align: right; +} + +/* Brush color dots */ +.brush-color-dot { + border-radius: 50%; + border: 3px solid transparent; + cursor: pointer; + transition: all var(--motion-fast); +} + +.brush-color-dot:hover { transform: scale(1.1); } +.brush-color-dot.active { border-color: var(--fg); box-shadow: 0 0 0 2px var(--surface); } + +/* Brush opacity */ +.brush-opacity-row { + display: flex; + align-items: center; + gap: var(--space-3); + transition: opacity var(--motion-fast); +} + +.brush-opacity-row.disabled { opacity: 0.35; pointer-events: none; } + +.brush-opacity-label { + font-size: var(--text-xs); + color: var(--muted); + font-weight: 500; + min-width: 40px; +} + +.brush-opacity-value { + font-size: var(--text-sm); + font-weight: 600; + color: var(--fg); + min-width: 36px; + text-align: right; +} + +/* Tag pill */ +.tag-selected-area { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + min-height: 36px; +} + +.tag-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 9999px; + background: var(--surface-warm); + color: var(--accent); + font-size: 13px; + font-weight: 500; + transition: all var(--motion-fast); +} + +.tag-pill .remove { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + color: var(--surface); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + cursor: pointer; + border: none; + line-height: 1; + transition: background var(--motion-fast); +} + +.tag-pill .remove:hover { background: var(--accent-hover); } + +/* Tag input */ +.tag-input-row { + display: flex; +} + +.tag-input { + flex: 1; + height: 44px; + border: 1.5px solid var(--border); + border-radius: var(--radius-pill); + padding: 0 var(--space-4); + font-size: var(--text-sm); + color: var(--fg); + background: var(--bg); + outline: none; + transition: border-color var(--motion-fast); + font-family: var(--font-body); +} + +.tag-input::placeholder { color: var(--meta); } +.tag-input:focus { border-color: var(--accent); box-shadow: var(--shadow-input-focus); } + +/* Tag suggest items */ +.tag-suggest-item { + display: inline-flex; + align-items: center; + padding: 6px 14px; + border-radius: 9999px; + background: var(--surface-warm); + color: var(--fg-2); + font-size: var(--text-sm); + font-weight: 500; + border: 1px solid transparent; + cursor: pointer; + white-space: nowrap; + min-height: 44px; + transition: all var(--motion-fast); +} + +.tag-suggest-item:hover { border-color: var(--accent); color: var(--accent); } + +/* Brush section title (tablet/desktop) */ +.brush-section-title { + font-size: var(--text-xs); + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: var(--space-3); +} + +/* Brush color grid (tablet/desktop) */ +.brush-colors-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-3); +} + +/* Tags section */ +.tags-section { + margin-bottom: var(--space-5); +} + +/* Tool panel side (tablet/desktop) */ +.tool-panel-side { + background: var(--surface); + border-left: 1px solid var(--border-soft); + overflow-y: auto; + scrollbar-width: thin; + flex-shrink: 0; +} + +.tool-panel-side::-webkit-scrollbar { width: 4px; } +.tool-panel-side::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +.panel-header { + padding: var(--space-5); + border-bottom: 1px solid var(--border-soft); + display: flex; + align-items: center; + gap: var(--space-3); +} + +/* Canvas wrapper scrollbar (tablet/desktop) */ +.canvas-wrapper { + flex: 1; + padding: var(--space-6); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.canvas-wrapper::-webkit-scrollbar { width: 4px; } +.canvas-wrapper::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +/* Canvas paper (tablet/desktop) */ +.canvas-paper { + background: var(--surface); + border-radius: var(--radius-md); + box-shadow: var(--elev-medium); + border: 1px solid var(--border-soft); + position: relative; +} + +/* Editor body flex layout (tablet/desktop) */ +.editor-body { + flex: 1; + display: flex; + overflow: hidden; +} diff --git a/docs/opendesign/css/tokens.css b/docs/opendesign/css/tokens.css new file mode 100644 index 0000000..f302a0b --- /dev/null +++ b/docs/opendesign/css/tokens.css @@ -0,0 +1,235 @@ +/* ───────────────────────────────────────────────────────── + * 暖记 (Warm Notes) — 手账日记App 视觉系统 + * Theme 1: 暖阳 (Warm Sun) — 温暖治愈 · 手绘插画风 · 默认 + * Theme 2: 松风 (Pine Wind) — 静谧森林 · 自然调性 · 男学生向 + * ───────────────────────────────────────────────────────── */ + +:root { + /* Surface */ + --bg: #FFF8F0; + --surface: #FFFFFF; + --surface-warm: #FFF3E6; + + /* Foreground ramp (contrast-safe on #FFF8F0) */ + --fg: #2D2420; + --fg-2: #5C4F47; + --muted: #7A6D63; + --meta: #8B7E74; + + /* Border */ + --border: #E8DDD4; + --border-soft: #F0E8DF; + + /* Accent — soft coral / terracotta */ + --accent: #E07A5F; + --accent-on: #FFF8F0; + --accent-hover: #D06A4F; + --accent-active: #C05A3F; + --accent-glow: rgba(224, 122, 95, 0.25); + + /* Secondary — sage green */ + --secondary: #81B29A; + --secondary-soft: #D4E8DC; + + /* Tertiary — warm gold */ + --tertiary: #F2CC8F; + --tertiary-soft: #FBE8C8; + + /* Rose */ + --rose: #D4A5A5; + --rose-soft: #F0DADA; + + /* Semantic */ + --success: #5A9E7E; + --warn: #D4A843; + --danger: #C93D3D; + + /* Typography */ + --font-display: "Quicksand", "Nunito", "SF Pro Rounded", -apple-system, system-ui, sans-serif; + --font-body: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: ui-monospace, "JetBrains Mono", monospace; + --font-handwritten: "Caveat", "Kalam", cursive; + + /* Type scale */ + --text-xs: 11px; + --text-sm: 13px; + --text-base: 15px; + --text-md: 17px; + --text-lg: 20px; + --text-xl: 24px; + --text-2xl: 30px; + --text-3xl: 38px; + --text-4xl: 48px; + + --leading-body: 1.6; + --leading-tight: 1.25; + + /* Spacing */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + + /* Radius */ + --radius-sm: 10px; + --radius-md: 16px; + --radius-lg: 22px; + --radius-xl: 28px; + --radius-pill: 9999px; + + /* Elevation */ + --elev-soft: 0 2px 12px rgba(45, 36, 32, 0.06); + --elev-medium: 0 4px 20px rgba(45, 36, 32, 0.08); + --elev-float: 0 8px 32px rgba(45, 36, 32, 0.12); + + /* Focus */ + --focus-ring: 0 0 0 3px rgba(224, 122, 95, 0.45); + --shadow-accent: 0 4px 14px rgba(224, 122, 95, 0.25); + --shadow-accent-hover: 0 6px 20px rgba(224, 122, 95, 0.35); + --shadow-input-focus: 0 0 0 3px rgba(224, 122, 95, 0.2); + --bg-frosted: rgba(255, 248, 240, 0.85); + + /* Touch target minimum (WCAG 2.5.8) */ + --touch-min: 44px; + + /* Motion */ + --motion-fast: 150ms; + --motion-base: 250ms; + --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-standard: cubic-bezier(0.2, 0, 0, 1); + + /* Layout */ + --container-max: 390px; + --safe-top: 54px; + --safe-bottom: 34px; + --tab-height: 56px; + + /* Theme meta */ + --theme-name: "暖阳"; +} + +/* ───────────────────────────────────────────────────────── + * Theme: 松风 (Pine Wind) — 森林书房 · 男学生向 + * Steel blue accent + forest green + warm amber + * ───────────────────────────────────────────────────────── */ +[data-theme="pine"] { + --bg: #F2F3F0; + --surface: #FFFFFF; + --surface-warm: #E9EAE6; + + --fg: #23272F; + --fg-2: #484E58; + --muted: #6E7380; + --meta: #8A8F9A; + + --border: #D5D2CD; + --border-soft: #E3E1DC; + + --accent: #4A7B9D; + --accent-on: #FFFFFF; + --accent-hover: #3F6A8A; + --accent-active: #345A78; + --accent-glow: rgba(74, 123, 157, 0.25); + + --secondary: #5B9E7A; + --secondary-soft: #D6E8DE; + + --tertiary: #C49A3C; + --tertiary-soft: #F0E4C8; + + --rose: #7A8B6A; + --rose-soft: #E0E8D8; + + --success: #4A9E6E; + --warn: #B89430; + --danger: #C93D3D; + + --elev-soft: 0 2px 12px rgba(35, 39, 47, 0.06); + --elev-medium: 0 4px 20px rgba(35, 39, 47, 0.08); + --elev-float: 0 8px 32px rgba(35, 39, 47, 0.12); + + --focus-ring: 0 0 0 3px rgba(74, 123, 157, 0.45); + --shadow-accent: 0 4px 14px rgba(74, 123, 157, 0.25); + --shadow-accent-hover: 0 6px 20px rgba(74, 123, 157, 0.35); + --shadow-input-focus: 0 0 0 3px rgba(74, 123, 157, 0.2); + --bg-frosted: rgba(242, 243, 240, 0.85); + + --theme-name: "松风"; +} + +/* ───────────────────────────────────────────────────────── + * Dark Mode — 暖阳 (Warm Sun) dark + * ───────────────────────────────────────────────────────── */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme]), + :root[data-theme="warm"] { + --bg: #1A1614; + --surface: #2A2520; + --surface-warm: #332D28; + --fg: #F0E8DF; + --fg-2: #C4B8AA; + --muted: #9B8E82; + --meta: #7A6D63; + --border: #3A3530; + --border-soft: #302B26; + --accent: #E8907A; + --accent-on: #1A1614; + --accent-hover: #D07A64; + --accent-active: #C06A54; + --accent-glow: rgba(232, 144, 122, 0.25); + --secondary: #8FBF9E; + --secondary-soft: #2A3A2E; + --tertiary: #D4B878; + --tertiary-soft: #302A1E; + --rose: #C4A0A0; + --rose-soft: #3A2A2A; + --success: #6AAF8E; + --warn: #C4A843; + --danger: #D94A4A; + --elev-soft: 0 2px 12px rgba(0, 0, 0, 0.2); + --elev-medium: 0 4px 20px rgba(0, 0, 0, 0.25); + --elev-float: 0 8px 32px rgba(0, 0, 0, 0.3); + --focus-ring: 0 0 0 3px rgba(232, 144, 122, 0.5); + --shadow-accent: 0 4px 14px rgba(232, 144, 122, 0.25); + --shadow-accent-hover: 0 6px 20px rgba(232, 144, 122, 0.35); + --shadow-input-focus: 0 0 0 3px rgba(232, 144, 122, 0.2); + --bg-frosted: rgba(26, 22, 20, 0.85); + * ───────────────────────────────────────────────────────── */ +@media (prefers-color-scheme: dark) { + [data-theme="pine"] { + --bg: #14161C; + --surface: #1C1E26; + --surface-warm: #22242C; + --fg: #E4E6EC; + --fg-2: #B4B8C4; + --muted: #7E8494; + --meta: #5E6474; + --border: #282C38; + --border-soft: #1E222C; + --accent: #5A8FAD; + --accent-on: #14161C; + --accent-hover: #4A7F9D; + --accent-active: #3A6F8D; + --accent-glow: rgba(90, 143, 173, 0.25); + --secondary: #6AAE85; + --secondary-soft: #1C2E24; + --tertiary: #D4AA4C; + --tertiary-soft: #2A2618; + --rose: #8A9B7A; + --rose-soft: #242E20; + --success: #5AAE7E; + --warn: #C4A440; + --danger: #D94A4A; + --elev-soft: 0 2px 12px rgba(0, 0, 0, 0.2); + --elev-medium: 0 4px 20px rgba(0, 0, 0, 0.25); + --elev-float: 0 8px 32px rgba(0, 0, 0, 0.3); + --focus-ring: 0 0 0 3px rgba(90, 143, 173, 0.5); + --shadow-accent: 0 4px 14px rgba(90, 143, 173, 0.25); + --shadow-accent-hover: 0 6px 20px rgba(90, 143, 173, 0.35); + --shadow-input-focus: 0 0 0 3px rgba(90, 143, 173, 0.2); + --bg-frosted: rgba(20, 22, 28, 0.85); diff --git a/docs/opendesign/index.html b/docs/opendesign/index.html new file mode 100644 index 0000000..6314ea6 --- /dev/null +++ b/docs/opendesign/index.html @@ -0,0 +1,611 @@ + + + + + +暖记 — 多终端手账App 设计稿 + + + + + +
+
Design System v1.0
+

暖记 · 手账日记

+

用温暖的方式,记录每一天。面向学生的多终端手账日记App,融合日记记录、贴纸装饰、模板系统、心情追踪、登录注册、发现探索与搜索。

+
+ 温暖治愈风 + iOS / Android / iPad / Desktop + 27 页面 · 3 终端 + 学生向 +
+
+ + +
+ + + +
+ + +
+
手机端 · 14个页面
+
iOS / Android — 从启动到日常使用的完整流程,含登录注册、发现探索、搜索筛选
+ +
+ + +
+
平板端 · 6个页面
+
iPad 1024×768 — 侧边栏导航 + 双列布局 + 分栏编辑器 + 登录注册 + 发现搜索
+ +
+ + +
+
桌面端 · 6个页面
+
1440×900 — 固定侧边栏 + 多列布局 + 高信息密度 + 登录注册 + 发现搜索
+ +
+ + +
+
视觉系统
+
暖记的色彩体系与排版规则
+
+
+
+
Background
+
#FFF8F0
+
+
+
+
Accent
+
#E07A5F
+
+
+
+
Secondary
+
#81B29A
+
+
+
+
Tertiary
+
#F2CC8F
+
+
+
+
Rose
+
#D4A5A5
+
+
+
+
Foreground
+
#2D2420
+
+
+
+
Muted
+
#8B7E74
+
+
+
+
Border
+
#E8DDD4
+
+
+
+ + +
+

暖记 Warm Notes — 多终端手账日记App设计稿

+

27 页面 · 手机 14 + 平板 6 + 桌面 6 · 温暖治愈视觉系统

+
+ + + + + + diff --git a/docs/opendesign/js/theme-switcher.js b/docs/opendesign/js/theme-switcher.js new file mode 100644 index 0000000..3ddeade --- /dev/null +++ b/docs/opendesign/js/theme-switcher.js @@ -0,0 +1,103 @@ +/* ───────────────────────────────────────────────────────── + * 暖记 Theme Switcher + * Handles theme toggling + localStorage persistence + * ───────────────────────────────────────────────────────── */ +(function () { + var THEMES = [ + { id: 'warm', name: '暖阳', swatch: '#E07A5F' }, + { id: 'pine', name: '松风', swatch: '#4A7B9D' } + ]; + var STORAGE_KEY = 'warmnotes-theme'; + + function getCurrent() { + return localStorage.getItem(STORAGE_KEY) || 'warm'; + } + + function apply(id) { + if (id === 'warm') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.setAttribute('data-theme', id); + } + localStorage.setItem(STORAGE_KEY, id); + } + + function cycle() { + var cur = getCurrent(); + var idx = 0; + for (var i = 0; i < THEMES.length; i++) { + if (THEMES[i].id === cur) { idx = i; break; } + } + var next = THEMES[(idx + 1) % THEMES.length]; + apply(next.id); + updateBtn(next); + } + + function updateBtn(theme) { + var btn = document.getElementById('theme-toggle-btn'); + if (!btn) return; + var dot = btn.querySelector('.theme-dot'); + var label = btn.querySelector('.theme-label'); + if (dot) dot.style.background = theme.swatch; + if (label) label.textContent = theme.name; + btn.setAttribute('aria-label', 'Switch theme (current: ' + theme.name + ')'); + } + + // Apply stored theme immediately (prevent flash) + var saved = getCurrent(); + apply(saved); + + // Wait for DOM then inject toggle button + function init() { + if (document.getElementById('theme-toggle-btn')) return; + + var cur = THEMES.filter(function (t) { return t.id === saved; })[0] || THEMES[0]; + + var wrap = document.createElement('div'); + wrap.id = 'theme-toggle-btn'; + wrap.setAttribute('role', 'button'); + wrap.setAttribute('tabindex', '0'); + wrap.setAttribute('aria-label', 'Switch theme (current: ' + cur.name + ')'); + wrap.onclick = function () { saved = getCurrent(); cycle(); }; + wrap.onkeydown = function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); cycle(); } }; + + var style = document.createElement('style'); + style.textContent = '\ +#theme-toggle-btn{position:fixed;top:8px;right:8px;z-index:10000;display:flex;align-items:center;gap:6px;\ +padding:5px 12px 5px 8px;border-radius:20px;border:1px solid var(--border);\ +background:var(--surface);color:var(--fg);font-size:11px;font-family:var(--font-body);\ +cursor:pointer;box-shadow:var(--elev-medium);transition:all .2s ease;user-select:none;line-height:1;}\ +#theme-toggle-btn:hover{box-shadow:var(--elev-float);}\ +#theme-toggle-btn:focus-visible{outline:none;box-shadow:var(--focus-ring);}\ +.theme-dot{width:14px;height:14px;border-radius:50%;flex-shrink:0;border:1.5px solid var(--border);}\ +.theme-label{white-space:nowrap;font-weight:600;}'; + document.head.appendChild(style); + + var dot = document.createElement('span'); + dot.className = 'theme-dot'; + dot.style.background = cur.swatch; + + var label = document.createElement('span'); + label.className = 'theme-label'; + label.textContent = cur.name; + + wrap.appendChild(dot); + wrap.appendChild(label); + document.body.appendChild(wrap); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // Cross-tab sync + window.addEventListener('storage', function (e) { + if (e.key === STORAGE_KEY && e.newValue) { + apply(e.newValue); + var t = THEMES.filter(function (th) { return th.id === e.newValue; })[0] || THEMES[0]; + updateBtn(t); + } + }); +})(); diff --git a/docs/opendesign/screens/calendar.html b/docs/opendesign/screens/calendar.html new file mode 100644 index 0000000..8b3a247 --- /dev/null +++ b/docs/opendesign/screens/calendar.html @@ -0,0 +1,514 @@ + + + + + +暖记 — 日历视图 + + + + + + + +
+ + +
+
2026年5月
+
+ + +
+
+ + +
+ + + +
+ + +
+

本月心情概览

+
+
😊
+
😐
+
😢
+
😡
+
🤔
+
+
+ 开心 12天 + 平静 8天 + 难过 2天 + 生气 1天 + 思考 3天 +
+
+ + + + + +
+ +
+
+
+
+
+
1
+
2
+ + +
3
+
4
+
5
+
6
+
7
+
8
+
9
+ + +
10
+
11
+
12
+
13
+
14
+
15
+
16
+ + +
17
+
18
+
19
+
20
+
21
+
22
+
23
+ + +
24
+
25
+
26
+
27
+
28
+
29
+
30
+ + +
31
+
+ + +
+
+

5月31日 · 今天

+ 3条记录 +
+ +
+ + +
+
14:30 · 心情: 开心
+
图书馆的午后时光
+
今天下午去图书馆自习,阳光从窗外洒进来,暖暖的...
+
+
+ +
+ + +
+
09:15 · 心情: 开心
+
周末早起的奖励
+
难得周末早起,去食堂吃了喜欢的豆浆油条,还买了一束花...
+
+
+ +
+ + +
+
07:00 · 心情: 平静
+
晨跑记录
+
早起跑了两圈操场,空气很清新。看到有人在遛小狗...
+
+
+
+ + +
+
📝
+
这一天没有记录
+
点击 + 开始记录这一天的心情和故事
+
+ +
+
+ + + + + + + + + + diff --git a/docs/opendesign/screens/desktop/calendar.html b/docs/opendesign/screens/desktop/calendar.html new file mode 100644 index 0000000..71453b4 --- /dev/null +++ b/docs/opendesign/screens/desktop/calendar.html @@ -0,0 +1,629 @@ + + + + + +暖记 — 桌面端日历 + + + + + + + +
+ + + +
+ +
+
+
日历
+
+ + +
+ 2026年5月 +
+
+ + + +
+
+ + +
+ +
+ + +
+
+ 周日周一周二周三周四周五周六 +
+
+
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
+
+ + + +
+ + + + +
+
+
+
+
+
+ 暖记 — 日历 + 14:32 +
+ + + + diff --git a/docs/opendesign/screens/desktop/discover.html b/docs/opendesign/screens/desktop/discover.html new file mode 100644 index 0000000..066c42a --- /dev/null +++ b/docs/opendesign/screens/desktop/discover.html @@ -0,0 +1,648 @@ + + + + + +暖记 — 桌面端发现 + + + + + + + + +
+
+
+
+
+
+ 暖记 Warm Notes + 14:32 +
+ + + + + +
+ +
+
发现
+
+ + +
+
+ +
+ + + + + +
+ +
没有找到相关内容
+
换个关键词试试吧
+
+ + +
+
+ +
+
今日推荐
+
旅行手账排版技巧
+
学习如何用简单的素材和排版技巧,让你的旅行手账充满故事感。从照片布局到文字装饰,一步步教你做出精美的旅行记录。
+
by 手账达人小林
+
+ +
+
+ + +
+ + +
+
+

热门话题

+ +
+
+ + + + + + +
+
+ + +
+
+

精选模板

+ +
+
+
+ +
+
考试周复习计划
+
+ + 2.3k 人使用 +
+
+
+
+ +
+
读书笔记手账
+
+ + 1.8k 人使用 +
+
+
+
+ +
+
月度心情打卡
+
+ + 3.1k 人使用 +
+
+
+
+ +
+
旅行日记模板
+
+ + 1.5k 人使用 +
+
+
+
+
+ + +
+
+

达人日记

+ +
+
+
+ +
+
小暖
+
我的大学生活第100天
+
不知不觉大学生活已经过了100天了,从最初的迷茫到现在的适应,记录下这段珍贵的时光...
+
+
+ + 328 +
+
+
+ +
+
手账少女
+
考研倒计时30天手账
+
最后冲刺阶段,用手账记录每一天的复习进度和心情变化,给自己加油打气...
+
+
+ + 512 +
+
+
+ +
+
阿月
+
秋日校园手账记录
+
银杏叶黄了,校园美得像画一样。收集了几片落叶夹在手账里,记录这个温柔的秋天...
+
+
+ + 276 +
+
+
+
+ +
+
+
+ + + + diff --git a/docs/opendesign/screens/desktop/editor.html b/docs/opendesign/screens/desktop/editor.html new file mode 100644 index 0000000..f1018b9 --- /dev/null +++ b/docs/opendesign/screens/desktop/editor.html @@ -0,0 +1,479 @@ + + + + + +暖记 — 桌面端编辑器 + + + + + + + + +
+ + +
+
+
+
5月31日 · 周日
14:32· 晴 26°
😊
🥰
+
+
+ + +
+
已保存
+ + + +
+
+ +
+
+ + + + +
+ + +
+ +
+ +
+
+
+
+
🌸
+
+
+ +
+ + + + +
+
+
+
+
+
+
+ +
+ +
图书馆窗边的阳光 ☀
+
+
+
+ +
+
+
+
+
+
+
🌸
🌿
☀️
🌙
🍰
📚
🎵
💡
🎀
🍂
🌈
✏️
🦋
🐱
🎈
🌻
🎨
🍀
🎊
💧
🌙
💌
🐱
+
+
+
+ + + + + + +
+
+
+
+ +
+
+ 暖记 — 手账编辑器 + 14:32 +
+ + + + + diff --git a/docs/opendesign/screens/desktop/home-daily.html b/docs/opendesign/screens/desktop/home-daily.html new file mode 100644 index 0000000..b73dc85 --- /dev/null +++ b/docs/opendesign/screens/desktop/home-daily.html @@ -0,0 +1,595 @@ + + + + + +暖记 — 桌面端首页 + + + + + + + + +
+
+
+
+
+
+ 暖记 Warm Notes + 14:32 +
+ + + + + +
+ +
+
首页
+
+ + +
+
+ +
+ +
+
+
2026年5月31日 · 星期日
+
下午好,小暖
+
+
+ + 连续记录 12 天 +
+
+ + +
+
+ +
+
+ 今天心情如何? +
+ + 晴 26° +
+
+
+ + + + + +
+
+ + +
+
+
今天的日记
+
写点什么吧...
+
记录一个温暖的瞬间,或者今天发生的小事
+
+ +
+ + +
+

最近记录

+ 查看全部 +
+
+
+
😊
+
图书馆的午后
+
今天在图书馆自习,窗外的阳光洒进来,暖暖的。复习了高数第三章...
+
🌸
+
+
+
🥰
+
和舍友的火锅局
+
考完试和舍友们去吃了火锅庆祝,大家都好开心...
+
🍃
+
+
+
😌
+
期末考试第一天
+
终于考完了英语,感觉发挥还可以。明天继续加油!
+
📝
+
+
+
😌
+
夜跑打卡
+
晚上去操场跑了三圈,吹着晚风特别舒服...
+
🌙
+
+
+
+ + +
+
+
28
本月日记
+
12
连续天数
+
156
总日记数
+
72%
好心情占比
+
+ +
+

心情趋势

+
+ + + +
+
+
25日
+
26日
+
27日
+
28日
+
29日
+
30日
+
31日
+
+
+ +
+

心情洞察

+
+ 🌟 + 最佳心情日:5月14日 + 开心 +
+
+ 💡 + 好心情常与「朋友」「美食」相关 +
+
+ 📈 + 心情较上月提升 + +15% +
+
+
+
+
+
+ + + + diff --git a/docs/opendesign/screens/desktop/login.html b/docs/opendesign/screens/desktop/login.html new file mode 100644 index 0000000..9082243 --- /dev/null +++ b/docs/opendesign/screens/desktop/login.html @@ -0,0 +1,725 @@ + + + + + +暖记 — 桌面端登录/注册 + + + + + + + +
+ + + + + + + + + + +
+ +
暖记
+
用温暖记录每一天
+ + +
+
+ +
记录每一天
+
用文字、贴纸和照片记录生活中的温暖瞬间
+
+
+ +
用贴纸装饰
+
丰富的手绘贴纸让你的日记更加生动有趣
+
+
+ +
追踪你的心情
+
记录并可视化心情变化,更好地了解自己
+
+
+
+
+ + +
+ + + +
+

欢迎回来

+ +
+ +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + + +
+
+ + +
+ + + +
+ + +
+ +
+ + + +
+ + +
其他登录方式
+ + + + + + +
+
+ + + + + + diff --git a/docs/opendesign/screens/desktop/search.html b/docs/opendesign/screens/desktop/search.html new file mode 100644 index 0000000..507cd57 --- /dev/null +++ b/docs/opendesign/screens/desktop/search.html @@ -0,0 +1,535 @@ + + + + + +暖记 — 桌面端搜索 + + + + + + + +
+ + + +
+ + +
+ +
+
+
+ 最近搜索 + +
+
+ + + + +
+
+
+
+ 热门搜索 +
+
+ + + + + + + + +
+
+
+ + + +
+
+
+ +
+
+ 暖记 — 搜索日记 + 14:32 +
+ + + + + + diff --git a/docs/opendesign/screens/desktop/shared.css b/docs/opendesign/screens/desktop/shared.css new file mode 100644 index 0000000..883468f --- /dev/null +++ b/docs/opendesign/screens/desktop/shared.css @@ -0,0 +1,312 @@ +/* ───────────────────────────────────────────────────────── + * 暖记 — 桌面端 (1440×900) 共享布局组件 + * ───────────────────────────────────────────────────────── */ + +/* Desktop sidebar (wider) */ +.sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: 280px; + background: var(--surface); + border-right: 1px solid var(--border-soft); + display: flex; + flex-direction: column; + z-index: 100; + padding: var(--space-6) 0; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: var(--space-4); + padding: 0 var(--space-6); + margin-bottom: var(--space-10); +} + +.sidebar-logo { + width: 48px; + height: 48px; + border-radius: var(--radius-md); + background: linear-gradient(135deg, var(--accent), var(--tertiary)); + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; +} + +.sidebar-brand-text { + font-family: var(--font-display); + font-size: var(--text-2xl); + font-weight: 700; + color: var(--fg); +} + +.sidebar-brand-sub { + font-size: var(--text-xs); + color: var(--muted); +} + +.sidebar-nav { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 var(--space-4); +} + +.sidebar-nav-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-5); + border-radius: var(--radius-sm); + font-size: var(--text-md); + font-weight: 500; + color: var(--muted); + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; + transition: all var(--motion-fast) var(--ease-standard); +} + +.sidebar-nav-item:hover { + background: var(--surface-warm); + color: var(--fg); +} + +.sidebar-nav-item.active { + background: var(--surface-warm); + color: var(--accent); + font-weight: 600; +} + +.sidebar-nav-item svg { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.sidebar-write-btn { + margin: var(--space-5) var(--space-5); + padding: var(--space-4) var(--space-6); + min-height: 36px; + background: var(--accent); + color: var(--accent-on); + border: none; + border-radius: var(--radius-md); + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-3); + transition: all var(--motion-fast) var(--ease-bounce); + box-shadow: var(--shadow-accent); +} + +.sidebar-write-btn:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-accent-hover); +} + +.sidebar-write-btn:active { transform: scale(0.97); } +.sidebar-write-btn svg { width: 20px; height: 20px; } + +.sidebar-footer { + padding: var(--space-4) var(--space-6); + border-top: 1px solid var(--border-soft); + display: flex; + align-items: center; + gap: var(--space-3); +} + +.sidebar-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--secondary-soft); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.sidebar-user-name { + font-size: var(--text-base); + font-weight: 600; + color: var(--fg); +} + +.sidebar-user-streak { + font-size: var(--text-xs); + color: var(--muted); +} + +/* Main content area */ +.main-content { + margin-left: 280px; + height: 100vh; + overflow-y: auto; + background: var(--bg); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.main-content::-webkit-scrollbar { width: 6px; } +.main-content::-webkit-scrollbar-track { background: transparent; } +.main-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +/* Desktop top bar */ +.desktop-topbar { + position: sticky; + top: 0; + background: var(--bg-frosted); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-soft); + padding: var(--space-4) var(--space-10); + display: flex; + align-items: center; + justify-content: space-between; + z-index: 50; +} + +.desktop-topbar-title { + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 700; + color: var(--fg); +} + +.desktop-topbar-actions { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.topbar-action-btn { + width: 38px; + height: 38px; + border-radius: var(--radius-pill); + border: 1px solid var(--border); + background: var(--surface); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--fg); + transition: all var(--motion-fast); +} + +.topbar-action-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.topbar-action-btn svg { width: 18px; height: 18px; } + +/* Three-column layout */ +.three-col { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: var(--space-5); +} + +/* Two-column wider */ +.two-col-wide { + display: grid; + grid-template-columns: 2fr 1fr; + gap: var(--space-6); +} + +.content-inner { + padding: var(--space-8) var(--space-10); + max-width: 1120px; +} + +/* Page header */ +.page-header { + margin-bottom: var(--space-6); +} + +.page-header h1 { + font-family: var(--font-display); + font-size: var(--text-3xl); + font-weight: 700; + color: var(--fg); +} + +.page-header-sub { + font-size: var(--text-base); + color: var(--muted); + margin-top: var(--space-1); +} + +/* Desktop status bar */ +.desktop-statusbar { + position: fixed; + top: 0; + left: 280px; + right: 0; + height: 32px; + background: var(--surface); + border-bottom: 1px solid var(--border-soft); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-6); + font-size: 13px; + color: var(--muted); + font-weight: 500; + z-index: 60; +} + +.desktop-statusbar .traffic-lights { + display: flex; + gap: 8px; +} + +.desktop-statusbar .dot { + width: 12px; + height: 12px; + border-radius: 50%; +} + +/* Focus visible styles for accessibility */ +button:focus-visible, +a:focus-visible, +[role="button"]:focus-visible { + box-shadow: var(--focus-ring); + outline: none; +} + +/* Desktop button minimum size */ +button, +[role="button"] { + min-height: 36px; +} + +/* Desktop hover enhancements */ +.topbar-action-btn:hover { + transform: translateY(-1px); +} + +.sidebar-nav-item:hover { + transform: translateX(2px); +} + +/* Window overflow handling for narrow viewports */ +.main-content { + min-width: 0; + overflow-x: hidden; +} + +.content-inner { + min-width: 0; +} diff --git a/docs/opendesign/screens/discover.html b/docs/opendesign/screens/discover.html new file mode 100644 index 0000000..ccad838 --- /dev/null +++ b/docs/opendesign/screens/discover.html @@ -0,0 +1,555 @@ + + + + + +暖记 — 发现 + + + + + + + +
+ + + + + +
+ +
没有找到相关内容
+
换个关键词试试吧
+
+ + +
+ + +
+
+

每日灵感

+ +
+
+
今日推荐
+
+ +
+
旅行手账排版技巧
+
by 手账达人小林
+
+
+
+
+ + +
+
+

热门话题

+ +
+
+ + + + + + +
+
+ + +
+
+

精选模板

+ +
+
+
+ +
+
考试周复习计划
+
+ + 2.3k 人使用 +
+
+
+
+ +
+
读书笔记手账
+
+ + 1.8k 人使用 +
+
+
+
+ +
+
月度心情打卡
+
+ + 3.1k 人使用 +
+
+
+
+ +
+
旅行日记模板
+
+ + 1.5k 人使用 +
+
+
+
+
+ + +
+
+

达人日记

+ +
+
+
+ +
+
小暖
+
我的大学生活第100天
+
不知不觉大学生活已经过了100天了,从最初的迷茫到现在的适应,记录下这段珍贵的时光...
+
+
+ + 328 +
+
+
+ +
+
手账少女
+
考研倒计时30天手账
+
最后冲刺阶段,用手账记录每一天的复习进度和心情变化,给自己加油打气...
+
+
+ + 512 +
+
+
+ +
+
阿月
+
秋日校园手账记录
+
银杏叶黄了,校园美得像画一样。收集了几片落叶夹在手账里,记录这个温柔的秋天...
+
+
+ + 276 +
+
+
+
+ +
+
+
+ + + + +
+
+ + + + diff --git a/docs/opendesign/screens/editor.html b/docs/opendesign/screens/editor.html new file mode 100644 index 0000000..abc97d2 --- /dev/null +++ b/docs/opendesign/screens/editor.html @@ -0,0 +1,1135 @@ + + + + + +暖记 — 手账编辑器 + + + + + + + + + +
+
+ +
+
5月31日 · 周日
+
+
+ + +
+
+ + 已保存 +
+ + +
+
+ + +
+
+ 14:32 + · 晴 26° +
+
+ + + + + +
+
+ + +
+ + + + + + + +
+ + + +
+ + + + + + + + + + +
+ + + +
+
+ +
图书馆窗边的阳光 ☀
+
+
+
+
+ + +
+
+ + + + + + +
+
+ + +
+
+ + + + + + + +
+
+
🌸
+
+
🌿
+
☀️
+
🌙
+
🍰
+
📚
+
🎵
+
💡
+
🎀
+
🍂
+
+
🌈
+
✏️
+
🦋
+
+
+ + + + + + + + + + + + + + + + diff --git a/docs/opendesign/screens/home-daily.html b/docs/opendesign/screens/home-daily.html new file mode 100644 index 0000000..7a41a00 --- /dev/null +++ b/docs/opendesign/screens/home-daily.html @@ -0,0 +1,519 @@ + + + + + +暖记 — 首页日记流 + + + + + + + +
+ + +
+
+
2026年5月31日 · 星期日
+
下午好,小暖
+
+ + + +
+ + +
+ + 连续记录 12 天 +
+ + +
+
+ 今天心情如何? +
+ + 晴 26° +
+
+
+ + + + + +
+
+ + +
+
今天的日记
+
写点什么吧...
+
记录一个温暖的瞬间,或者今天发生的小事
+ +
+ + +
+
+
28
+
本月日记
+
+
+
12
+
连续天数
+
+
+
156
+
总日记数
+
+
+ + +
+

最近记录

+ 查看全部 +
+ +
+
🌸
+ +
😊
+
+ +
+
🍃
+ +
😊
+
+ +
+
📝
+ +
😐
+
+ +
+
🌙
+ +
😐
+
+ + +
+
📝
+
还没有日记
+
点击上方按钮,开始记录你的第一篇日记吧
+
+ +
+
+ + + + + + + + + + diff --git a/docs/opendesign/screens/login.html b/docs/opendesign/screens/login.html new file mode 100644 index 0000000..4bc8765 --- /dev/null +++ b/docs/opendesign/screens/login.html @@ -0,0 +1,644 @@ + + + + + +暖记 — 登录/注册 + + + + + + + + +
+ + + + + + + + + + +
暖记
+
用温暖记录每一天
+
+ + +
+ +

欢迎回来

+ +
+ +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + + +
+
+ + +
+ + + +
+ + +
+ +
+ + + +
+ + +
其他登录方式
+ + + + + + + +
+ + + + + + + + + diff --git a/docs/opendesign/screens/monthly.html b/docs/opendesign/screens/monthly.html new file mode 100644 index 0000000..a5ec0d7 --- /dev/null +++ b/docs/opendesign/screens/monthly.html @@ -0,0 +1,306 @@ + + + + + +暖记 — 月度概览 + + + + + + + +
+ +
+
2026年5月
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+
1😊
+
2😊
+ +
3😐
+
4😢
+
5😊
+
6😊
+
7😐
+
8😊
+
9😡
+ +
10😐
+
11😊
+
12😐
+
13😊
+
14😊
+
15😐
+
16😊
+ +
17😢
+
18😐
+
19😊
+
20😊
+
21😊
+
22😐
+
23😊
+ +
24😊
+
25😐
+
26😐
+
27😊
+
28😊
+
29😊
+
30😊
+ +
31😊
+
+
+ + +
+

本月总结

+
+
+
📝
+
28
+
日记篇数
+
+
+
🔥
+
12
+
最长连续
+
+
+
😊
+
72%
+
好心情占比
+
+
+
📸
+
18
+
照片数量
+
+
+
+ + +
+

本月精选

+ +
+
😊
+
+
5月14日
+
和朋友聚餐的欢乐时光
+
+
最佳心情
+
+ +
+
+
+
5月21日
+
完成了第一个小目标
+
+
里程碑
+
+ +
+
📚
+
+
5月28日
+
期末考试结束
+
+
最详尽记录
+
+
+ +
+
+ + + + + + + + + + diff --git a/docs/opendesign/screens/mood-tracker.html b/docs/opendesign/screens/mood-tracker.html new file mode 100644 index 0000000..4aaa4bb --- /dev/null +++ b/docs/opendesign/screens/mood-tracker.html @@ -0,0 +1,433 @@ + + + + + +暖记 — 心情追踪 + + + + + + + +
+ +
心情与天气
+ + +
+
今日心情 · 5月31日
+
😊
+
开心的一天
+
在图书馆享受了安静的午后时光
+
+ + +
+

今日天气

+
+ + + + + +
+
+ + +
+

心情趋势

+
+ + + +
+
+
😊 开心
25日
+
😐 平静
26日
+
😊 开心
27日
+
😊 开心
28日
+
😊 开心
29日
+
😊 开心
30日
+
😊 开心
31日
+
+
+ + +
+
+
😊
+
72%
+
本月好心情占比
+
+
+
🔥
+
12天
+
最长连续记录
+
+
+
☀️
+
18天
+
晴天记录次数
+
+
+
📝
+
28篇
+
本月日记篇数
+
+
+ + +
+

心情洞察

+
+
🌟
+
+
最佳心情日
+
5月14日 — 和朋友聚餐的日子
+
+
开心
+
+
+
💡
+
+
开心触发因素
+
你的好心情常与「朋友」「美食」「学习」相关
+
+
+
+
📊
+
+
心情较上月提升
+
好心情天数增加了 15%
+
+
+15%
+
+
+ +
+
+ + + + + + + + + + diff --git a/docs/opendesign/screens/onboarding.html b/docs/opendesign/screens/onboarding.html new file mode 100644 index 0000000..932e34a --- /dev/null +++ b/docs/opendesign/screens/onboarding.html @@ -0,0 +1,310 @@ + + + + + +暖记 — 引导页 + + + + + + + + + +
+ + +
+ +
+ +
+
用手账的方式
记录每一天
+

+ 文字、贴纸、涂鸦、照片 — 选择你喜欢的方式,把日常变成一本温暖的手账 +

+
+ + +
+ +
+ +
+
海量贴纸与模板
随心装饰
+

+ 数百款手绘贴纸、精美模板和装饰素材,让你的日记独一无二 +

+
+ + +
+ +
+ +
+
回顾成长轨迹
看见自己的变化
+

+ 心情追踪、日历回顾、统计洞察 — 不仅仅是记录,更是了解自己的旅程 +

+
+ +
+ + +
+ + +
+ + + + + + + + diff --git a/docs/opendesign/screens/profile.html b/docs/opendesign/screens/profile.html new file mode 100644 index 0000000..822071c --- /dev/null +++ b/docs/opendesign/screens/profile.html @@ -0,0 +1,330 @@ + + + + + +暖记 — 个人中心 + + + + + + + +
+ + +
+ +
小暖
+
每一天都值得被温柔记录
+
+ + +
+
+
156
+
总日记
+
+
+
12
+
连续天数
+
+
+
28
+
本月日记
+
+
+
342
+
使用贴纸
+
+
+ + +
+

成就徽章

+
+
+ +
7天连续
+
+
+ +
百篇日记
+
+
+ +
贴纸达人
+
+
+ +
年度记录
+
+
+ +
30天连续
+
+
+
+ + +
+

设置

+
+
+ +
+
日记提醒
+
每天 21:00 提醒写日记
+
+
+
+
+ +
+
隐私锁
+
Face ID 解锁查看日记
+
+ +
+
+ +
+
云端同步
+
已同步 · 上次 2分钟前
+
+ +
+
+ +
+
主题外观
+
暖色系 · 默认字体
+
+ +
+
+
+ + +
+
+
+ +
+
导出数据
+
+ +
+
+ +
+
意见反馈
+
+ +
+
+ +
+
关于暖记
+
版本 1.0.0
+
+ +
+
+
+ +
+
+ + + + + + + + + + diff --git a/docs/opendesign/screens/search.html b/docs/opendesign/screens/search.html new file mode 100644 index 0000000..c754d0a --- /dev/null +++ b/docs/opendesign/screens/search.html @@ -0,0 +1,507 @@ + + + + + +暖记 — 搜索 + + + + + + + + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+
+ 最近搜索 + +
+
+ + + + +
+
+ + +
+
+ 热门搜索 +
+
+ + + + + + + + +
+
+
+ + + + +
+
+ + + + + + + + + diff --git a/docs/opendesign/screens/splash.html b/docs/opendesign/screens/splash.html new file mode 100644 index 0000000..38ca74a --- /dev/null +++ b/docs/opendesign/screens/splash.html @@ -0,0 +1,208 @@ + + + + + +暖记 — 启动页 + + + + + + + + + + + + + + +
+
+ + +
+
暖记
+
用温暖的方式,记录每一天
+
+ +
+ + +
每一天都值得被温柔记录
+
+ + + + + + diff --git a/docs/opendesign/screens/stickers.html b/docs/opendesign/screens/stickers.html new file mode 100644 index 0000000..c94c1ca --- /dev/null +++ b/docs/opendesign/screens/stickers.html @@ -0,0 +1,431 @@ + + + + + +暖记 — 贴纸素材库 + + + + + + + +
+ + +
+ +

贴纸素材

+
+
+ + + + + +
+ + + + + + + + +
+ + + + + +
+

热门素材包

+ 查看全部 +
+
+
+
☕📚
+
+
学习日常
+
24款贴纸
+
免费
+
+
+
+
🌿🍃
+
+
清新植物
+
36款贴纸
+
¥6
+
+
+
+
🎀💕
+
+
甜蜜少女
+
28款贴纸
+
¥3
+
+
+
+
🦋✨
+
+
梦幻星空
+
20款贴纸
+
免费
+
+
+
+ + +
+

精选贴纸

+ 收藏夹 +
+
+
🌸
+
+
🌿
+
☀️
+
🍰
+
🎵
+
📚
+
🌙
+
🎀
+
🍂
+
+
🦋
+
+ + +
+
💜
+
收藏夹是空的
+
长按贴纸可添加到收藏夹
+
+ +
+
+ + + + + + + diff --git a/docs/opendesign/screens/tablet/calendar.html b/docs/opendesign/screens/tablet/calendar.html new file mode 100644 index 0000000..aa68220 --- /dev/null +++ b/docs/opendesign/screens/tablet/calendar.html @@ -0,0 +1,485 @@ + + + + + +暖记 — 平板端日历 + + + + + + + +
+ + + +
+ +
+
+
日历
+
+ + +
+ 2026年5月 +
+
+ + + +
+
+ + +
+
+ +
+

本月心情概览

+
+
😊
+
🥰
+
😌
+
😔
+
😴
+
+
+ 开心 12天 + 幸福 8天 + 平静 5天 + 低落 2天 + 疲惫 3天 +
+
+ + +
+ +
+
+
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
+
+ + +
+ +
+ + + + + + diff --git a/docs/opendesign/screens/tablet/discover.html b/docs/opendesign/screens/tablet/discover.html new file mode 100644 index 0000000..d69e7af --- /dev/null +++ b/docs/opendesign/screens/tablet/discover.html @@ -0,0 +1,638 @@ + + + + + +暖记 — 平板端发现 + + + + + + + + + + + +
+
+ + + + + + + + +
+ +
没有找到相关内容
+
换个关键词试试吧
+
+ + +
+ + +
+ + +
+
+

热门话题

+ +
+
+ + + + + + +
+
+ + +
+
+

达人日记

+ +
+
+
+ +
+
小暖
+
我的大学生活第100天
+
不知不觉大学生活已经过了100天了,从最初的迷茫到现在的适应,记录下这段珍贵的时光...
+
+
+ + 328 +
+
+
+ +
+
手账少女
+
考研倒计时30天手账
+
最后冲刺阶段,用手账记录每一天的复习进度和心情变化,给自己加油打气...
+
+
+ + 512 +
+
+
+ +
+
阿月
+
秋日校园手账记录
+
银杏叶黄了,校园美得像画一样。收集了几片落叶夹在手账里,记录这个温柔的秋天...
+
+
+ + 276 +
+
+
+
+ +
+ + +
+ + +
+
+

精选模板

+ +
+
+
+ +
+
考试周复习计划
+
+ + 2.3k 人使用 +
+
+
+
+ +
+
读书笔记手账
+
+ + 1.8k 人使用 +
+
+
+
+ +
+
月度心情打卡
+
+ + 3.1k 人使用 +
+
+
+
+ +
+
旅行日记模板
+
+ + 1.5k 人使用 +
+
+
+
+
+ + +
+
+

每日灵感

+ +
+
+
今日推荐
+
+ +
+
旅行手账排版技巧
+
by 手账达人小林
+
+
+
+
+ +
+
+ +
+
+ + + + diff --git a/docs/opendesign/screens/tablet/editor.html b/docs/opendesign/screens/tablet/editor.html new file mode 100644 index 0000000..d663ef2 --- /dev/null +++ b/docs/opendesign/screens/tablet/editor.html @@ -0,0 +1,892 @@ + + + + + +暖记 — 平板端编辑器 + + + + + + + + +
+ + + +
+ +
+
+ +
+
5月31日 · 周日
+
+
+ + +
+
+ + 已保存 +
+ + +
+
+ + +
+
+ 14:32 + · 晴 26° +
+
+
😊
+
🥰
+
😌
+
+
+ + +
+
+
+
+
+
🌸
+
+ +
+ + +
+ + + +
+
+
+
+
+
+ +
+ + + +
+
+ +
图书馆窗边的阳光 ☀
+
+
+
+
+
+ + +
+
+ + + + + +
+ + +
+
+ + + + + +
+ +
+
🌸
+
+
🌿
+
☀️
+
🌙
+
🍰
+
📚
+
🎵
+
💡
+
🎀
+
🍂
+
+
🌈
+
✏️
+
🦋
+
🐱
+
🌸
+
🎈
+
🌻
+
🎨
+
+
+ + + + + + + + + + + + +
+
+
+
+ + + + + + diff --git a/docs/opendesign/screens/tablet/home-daily.html b/docs/opendesign/screens/tablet/home-daily.html new file mode 100644 index 0000000..1f32696 --- /dev/null +++ b/docs/opendesign/screens/tablet/home-daily.html @@ -0,0 +1,456 @@ + + + + + +暖记 — 平板端首页 + + + + + + + + + + + +
+
+ + +
+
2026年5月31日 · 星期日
+
下午好,小暖
+
+ +
+ + 连续记录 12 天 +
+ + +
+
+
+ 今天心情如何? +
+ + + + 晴 26° +
+
+
+ + + + + +
+
+ +
+
+
今天的日记
+
写点什么吧...
+
记录一个温暖的瞬间,或者今天发生的小事
+
+ +
+
+ + +
+
+
28
+
本月日记
+
+
+
12
+
连续天数
+
+
+
156
+
总日记数
+
+
+ + +
+

最近记录

+ 查看全部 +
+ +
+
+
+ +
😊
+
+
图书馆的午后
+
今天在图书馆自习,窗外的阳光洒进来,暖暖的。复习了高数第三章...
+
🌸
+
+
+
+ +
🥰
+
+
和舍友的火锅局
+
考完试和舍友们去吃了火锅庆祝,大家都好开心,聊了很多有趣的事...
+
🍃
+
+
+
+ +
😌
+
+
期末考试第一天
+
终于考完了英语,感觉发挥还可以。明天继续加油!给自己打个气...
+
📝
+
+
+
+ +
😌
+
+
夜跑打卡
+
晚上去操场跑了三圈,吹着晚风特别舒服。回来洗了个热水澡...
+
🌙
+
+
+ +
+
+ + + + diff --git a/docs/opendesign/screens/tablet/login.html b/docs/opendesign/screens/tablet/login.html new file mode 100644 index 0000000..8048db2 --- /dev/null +++ b/docs/opendesign/screens/tablet/login.html @@ -0,0 +1,622 @@ + + + + + +暖记 — 平板端登录/注册 + + + + + + + + + + + + + + diff --git a/docs/opendesign/screens/tablet/search.html b/docs/opendesign/screens/tablet/search.html new file mode 100644 index 0000000..b098cc1 --- /dev/null +++ b/docs/opendesign/screens/tablet/search.html @@ -0,0 +1,471 @@ + + + + + +暖记 — 平板端搜索 + + + + + + + +
+
+ + +
+ +
+
+
+ 最近搜索 + +
+
+ + + + +
+
+
+
+ 热门搜索 +
+
+ + + + + + + + +
+
+
+ + + +
+
+
+ + + + + + diff --git a/docs/opendesign/screens/tablet/shared.css b/docs/opendesign/screens/tablet/shared.css new file mode 100644 index 0000000..09c1421 --- /dev/null +++ b/docs/opendesign/screens/tablet/shared.css @@ -0,0 +1,227 @@ +/* ───────────────────────────────────────────────────────── + * 暖记 — 平板端 (iPad 1024×768) 共享布局组件 + * ───────────────────────────────────────────────────────── */ + +/* Sidebar navigation */ +.sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: 260px; + background: var(--surface); + border-right: 1px solid var(--border-soft); + display: flex; + flex-direction: column; + z-index: 100; + padding: var(--space-6) 0; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-6); + margin-bottom: var(--space-8); +} + +.sidebar-logo { + width: 42px; + height: 42px; + border-radius: var(--radius-md); + background: linear-gradient(135deg, var(--accent), var(--tertiary)); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.sidebar-brand-text { + font-family: var(--font-display); + font-size: var(--text-xl); + font-weight: 700; + color: var(--fg); +} + +.sidebar-brand-sub { + font-size: var(--text-xs); + color: var(--muted); +} + +.sidebar-nav { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 var(--space-3); +} + +.sidebar-nav-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + min-height: 44px; + border-radius: var(--radius-sm); + font-size: var(--text-base); + font-weight: 500; + color: var(--muted); + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; + transition: all var(--motion-fast) var(--ease-standard); +} + +.sidebar-nav-item:hover { + background: var(--surface-warm); + color: var(--fg); +} + +.sidebar-nav-item.active { + background: var(--surface-warm); + color: var(--accent); + font-weight: 600; +} + +.sidebar-nav-item svg { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.sidebar-write-btn { + margin: var(--space-4) var(--space-5); + padding: var(--space-4) var(--space-5); + min-height: 44px; + background: var(--accent); + color: var(--accent-on); + border: none; + border-radius: var(--radius-md); + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + transition: all var(--motion-fast) var(--ease-bounce); + box-shadow: var(--shadow-accent); +} + +.sidebar-write-btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-accent-hover); +} + +.sidebar-write-btn:active { transform: scale(0.97); } +.sidebar-write-btn svg { width: 20px; height: 20px; } + +.sidebar-footer { + padding: var(--space-4) var(--space-5); + border-top: 1px solid var(--border-soft); + display: flex; + align-items: center; + gap: var(--space-3); +} + +.sidebar-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--secondary-soft); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; +} + +.sidebar-user-name { + font-size: var(--text-sm); + font-weight: 600; + color: var(--fg); +} + +.sidebar-user-streak { + font-size: var(--text-xs); + color: var(--muted); +} + +/* Main content area for tablet */ +.main-content { + margin-left: 260px; + height: 100vh; + overflow-y: auto; + background: var(--bg); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.main-content::-webkit-scrollbar { width: 6px; } +.main-content::-webkit-scrollbar-track { background: transparent; } +.main-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +.content-inner { + padding: var(--space-8) var(--space-10); + max-width: 740px; +} + +/* Two-column layout */ +.two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-5); +} + +/* Page header for tablet */ +.page-header { + margin-bottom: var(--space-6); +} + +.page-header h1 { + font-family: var(--font-display); + font-size: var(--text-3xl); + font-weight: 700; + color: var(--fg); +} + +.page-header-sub { + font-size: var(--text-base); + color: var(--muted); + margin-top: var(--space-1); +} + +/* Tablet status bar (simplified) */ +.tablet-statusbar { + position: fixed; + top: 0; + left: 260px; + right: 0; + height: 28px; + background: var(--surface); + border-bottom: 1px solid var(--border-soft); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-5); + font-size: 12px; + color: var(--muted); + font-weight: 500; + z-index: 50; +} + +/* Focus visible styles for accessibility */ +button:focus-visible, +a:focus-visible, +[role="button"]:focus-visible { + box-shadow: var(--focus-ring); + outline: none; +} + +/* Minimum touch target for tablet */ +button, +[role="button"] { + min-height: 44px; +} diff --git a/docs/opendesign/screens/templates.html b/docs/opendesign/screens/templates.html new file mode 100644 index 0000000..8d97308 --- /dev/null +++ b/docs/opendesign/screens/templates.html @@ -0,0 +1,461 @@ + + + + + +暖记 — 模板画廊 + + + + + + + +
+ + +
+ +

模板画廊

+
+
+ + +
+ + + +
+ + +
+ + +
+
+
+
期末考试复习计划
+
+
+
+
+
+
+
+
+
+
+
+
+

期末考试复习计划

+
按科目安排复习进度,记录重点难点
+
+ +
+
+ 学生专属 + 2.3k 人使用 +
+
+ + +
+
+
+
📚
周一
+
✏️
周二
+
📖
周三
+
🔬
周四
+
+
+
+
+

课程表周计划

+
每周课程安排+作业追踪
+
+ +
+
+ 学生专属 + 5.1k 人使用 +
+
+ + +
+
+
+
+
+
+
+
+
+
+
📖
+
+
+
+
+

读书笔记手账

+
摘录+感悟+思维导图
+
+ +
+
+ 学生专属 + 3.8k 人使用 +
+
+ + +
+
+
+
校园生活记录
+
+
+
+
+
+
+
+
+
+
+
+
+

校园生活记录

+
社团活动+友谊+成长
+
+ +
+
+ 学生专属 + 1.9k 人使用 +
+
+ + +
+
+
+
2026年5月
+
+
1
2
+
3
4
5
6
7
8
9
+
+
+
+
+
+

月历手账

+
月度日历 + 心情色彩,一整月一目了然
+
+ +
+
+ 月视图 +
+
+ +
+ +
+
+ + + + + + + diff --git a/docs/opendesign/screens/weekly.html b/docs/opendesign/screens/weekly.html new file mode 100644 index 0000000..5623d3a --- /dev/null +++ b/docs/opendesign/screens/weekly.html @@ -0,0 +1,285 @@ + + + + + +暖记 — 周概览 + + + + + + + +
+ +
+
本周概览
+
+ + +
+
+ + +
+
+
25
😊
+
+
+
26
😐
+
+
+
27
😊
+
+
+
28
😊
+
+
+
29
😊
+
+
+
30
😊
+
+
+
31
😊
+
+
+ + +
+

本周总结

+
+
+
6
记录天数
+
+
+
7
日记篇数
+
+
+
12
使用贴纸
+
+
+
+
+
+
+
+
+ + +
+
+ 周日 · 5月31日 +
😊 ☀️
+
+
今天下午去图书馆自习,阳光从窗外洒进来,暖暖的。喝了抹茶拿铁,虽然期末压力大但看到窗外的樱花还在开,觉得一切都会好的。
+
+ 学习 + 美食 +
+
📚
+
+ +
+
+ 周六 · 5月30日 +
😊 🌤
+
+
今天在图书馆自习,窗外的阳光洒进来,暖暖的。复习了高数第三章,做了两套模拟题感觉还不错。
+
+ 学习 +
+
+ +
+
+ 周五 · 5月29日 +
😊 ☀️
+
+
考完试和舍友们去吃了火锅庆祝,大家都好开心,聊了很多有趣的事。这学期终于结束了!
+
+ 朋友 + 美食 +
+
🍲
+
+ +
+
+ + + + + + + + + + diff --git a/docs/opendesign/warm-notes-design-spec.md b/docs/opendesign/warm-notes-design-spec.md new file mode 100644 index 0000000..4fc1a87 --- /dev/null +++ b/docs/opendesign/warm-notes-design-spec.md @@ -0,0 +1,1121 @@ +# 暖记 (Warm Notes) — 完整设计规格文档 + +> **面向 LLM 的设计还原参考** — 本文档包含暖记手账日记 App 的完整视觉系统、组件规范、布局结构、交互状态和响应式行为。前端开发者(或 AI 编码助手)应以此文档为设计还原的唯一依据。 + +--- + +## 1. 项目概览 + +| 属性 | 值 | +|------|------| +| App 名称 | 暖记 (Warm Notes) | +| 定位 | 面向学生的多终端手账日记 App | +| 视觉风格 | 温暖治愈 / 手绘插画风 | +| 目标平台 | iOS、Android、iPad/平板、桌面客户端 | +| 设计稿页面 | 27 页面(手机 14 + 平板 6 + 桌面 6 + 启动页 1) | +| 主题系统 | 双主题:暖阳(默认)+ 松风 + 各自暗色模式 | +| 技术约束 | 手机视口 390×844(iPhone 15 Pro),平板 1024×768,桌面 1440×900 | + +--- + +## 2. 设计 Token 系统 + +### 2.1 颜色系统 + +#### 主题 1:暖阳 (Warm Sun) — 默认 + +```css +:root { + /* Surface 表面 */ + --bg: #FFF8F0; /* 页面背景:暖白 */ + --surface: #FFFFFF; /* 卡片/面板背景:纯白 */ + --surface-warm: #FFF3E6; /* 温暖表面:浅橙,用于 hover 态和选中态背景 */ + + /* Foreground 前景 */ + --fg: #2D2420; /* 主文字:深棕黑 */ + --fg-2: #5C4F47; /* 次要文字:中棕 */ + --muted: #7A6D63; /* 辅助文字:灰棕 */ + --meta: #8B7E74; /* 元数据/标签文字:浅灰棕 */ + + /* Border 边框 */ + --border: #E8DDD4; /* 标准边框 */ + --border-soft: #F0E8DF; /* 柔和边框:分隔线、卡片边框 */ + + /* Accent 主色调 — 柔和珊瑚/赤陶橙 */ + --accent: #E07A5F; /* 主强调色:珊瑚橙 */ + --accent-on: #FFF8F0; /* 主强调色上的文字色 */ + --accent-hover: #D06A4F; /* 主强调色 hover 态 */ + --accent-active: #C05A3F; /* 主强调色 active/按下态 */ + --accent-glow: rgba(224, 122, 95, 0.25); /* 强调色光晕 */ + + /* Secondary 辅助色 — 鼠尾草绿 */ + --secondary: #81B29A; + --secondary-soft: #D4E8DC; /* 浅绿背景 */ + + /* Tertiary 第三色 — 暖金 */ + --tertiary: #F2CC8F; + --tertiary-soft: #FBE8C8; /* 浅金背景 */ + + /* Rose 玫瑰色 */ + --rose: #D4A5A5; + --rose-soft: #F0DADA; /* 浅玫瑰背景 */ + + /* Semantic 语义色 */ + --success: #5A9E7E; /* 成功/正向 */ + --warn: #D4A843; /* 警告 */ + --danger: #C93D3D; /* 危险/错误 */ +} +``` + +#### 主题 2:松风 (Pine Wind) — 男学生向 + +激活方式:`document.documentElement.setAttribute('data-theme', 'pine')` + +```css +[data-theme="pine"] { + --bg: #F2F3F0; /* 冷灰白背景 */ + --surface: #FFFFFF; + --surface-warm: #E9EAE6; + + --fg: #23272F; /* 深灰黑文字 */ + --fg-2: #484E58; + --muted: #6E7380; + --meta: #8A8F9A; + + --border: #D5D2CD; + --border-soft: #E3E1DC; + + --accent: #4A7B9D; /* 钢蓝色主强调 */ + --accent-on: #FFFFFF; + --accent-hover: #3F6A8A; + --accent-active: #345A78; + --accent-glow: rgba(74, 123, 157, 0.25); + + --secondary: #5B9E7A; /* 森林绿 */ + --secondary-soft: #D6E8DE; + + --tertiary: #C49A3C; /* 琥珀金 */ + --tertiary-soft: #F0E4C8; + + --rose: #7A8B6A; /* 橄榄绿 */ + --rose-soft: #E0E8D8; +} +``` + +#### 暗色模式 + +通过 `@media (prefers-color-scheme: dark)` 自动切换。暖阳暗色: + +```css +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg: #1A1614; + --surface: #2A2520; + --surface-warm: #332D28; + --fg: #F0E8DF; + --fg-2: #C4B8AA; + --muted: #9B8E82; + --meta: #7A6D63; + --border: #3A3530; + --border-soft: #302B26; + --accent: #E8907A; /* 暗色下珊瑚橙偏亮 */ + --accent-on: #1A1614; + /* ...其余 token 自动适配 */ + } +} +``` + +**关键实现要点:** +- 颜色系统全部通过 CSS 自定义属性(Custom Properties)定义,主题切换只需切换 `data-theme` 属性 +- 所有组件和页面必须引用 CSS 变量,禁止硬编码颜色值 +- 暗色模式跟随系统偏好自动切换,无需用户手动操作 + +--- + +### 2.2 字体排版 + +```css +/* 字体家族 */ +--font-display: "Quicksand", "Nunito", "SF Pro Rounded", -apple-system, system-ui, sans-serif; +--font-body: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; +--font-mono: ui-monospace, "JetBrains Mono", monospace; +--font-handwritten: "Caveat", "Kalam", cursive; + +/* 字号阶梯 */ +--text-xs: 11px; /* 极小标签、元数据 */ +--text-sm: 13px; /* 小号文字、辅助信息 */ +--text-base: 15px; /* 正文基准 */ +--text-md: 17px; /* 中号,强调正文 */ +--text-lg: 20px; /* 小标题 */ +--text-xl: 24px; /* 区域标题 */ +--text-2xl: 30px; /* 页面标题 */ +--text-3xl: 38px; /* 大标题 */ +--text-4xl: 48px; /* 超大标题/展示用 */ + +/* 行高 */ +--leading-body: 1.6; /* 正文行高 */ +--leading-tight: 1.25; /* 紧凑行高,用于标题 */ +``` + +**字体使用规则:** +- **Display (Quicksand)** — 页面标题、区域标题、Tab 标签、按钮文字。特征:圆润、几何感、友好 +- **Body (Nunito)** — 正文段落、列表内容、描述文字。特征:清晰、可读性好 +- **Handwritten (Caveat)** — 装饰性文字、心情区域标题、手写风标签。特征:手写感、温暖 +- **Mono** — 仅用于数据展示、时间戳 + +**实现注意事项:** +- Quicksand 和 Nunito 需从 Google Fonts 加载:`https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap` +- Caveat 手写字体:`https://fonts.googleapis.com/css2?family=Caveat:wght@400;600;700&display=swap` +- 字重使用:Regular (400), Medium (500), SemiBold (600), Bold (700) +- 全局开启字体平滑:`-webkit-font-smoothing: antialiased` + +--- + +### 2.3 间距系统 + +```css +--space-1: 4px; +--space-2: 8px; +--space-3: 12px; +--space-4: 16px; +--space-5: 20px; +--space-6: 24px; +--space-8: 32px; +--space-10: 40px; +--space-12: 48px; +``` + +**间距使用规则:** +- `space-1` (4px):图标与文字间距、微调 +- `space-2` (8px):紧凑元素间距、Checkbox 与文字 +- `space-3` (12px):同行元素间距、内边距紧凑 +- `space-4` (16px):标准卡片内边距、表单项间距 +- `space-5` (20px):页面水平内边距、卡片间距 +- `space-6` (24px):区域分隔、大卡片内边距 +- `space-8` (32px):大区域分隔 +- `space-10` (40px):页面级间距 + +--- + +### 2.4 圆角 + +```css +--radius-sm: 10px; /* 小元素:输入框、小卡片 */ +--radius-md: 16px; /* 标准卡片 */ +--radius-lg: 22px; /* 大卡片、特殊区域 */ +--radius-xl: 28px; /* 全宽面板、底部弹窗 */ +--radius-pill: 9999px; /* 胶囊按钮、标签、头像 */ +``` + +**关键规则:** +- 所有卡片和交互元素都有明显的圆角(最小 10px) +- 按钮统一使用 pill 圆角(全圆) +- 不使用直角矩形(除特殊装饰元素外) + +--- + +### 2.5 阴影与高度 + +```css +--elev-soft: 0 2px 12px rgba(45, 36, 32, 0.06); /* 轻浮起:卡片默认 */ +--elev-medium: 0 4px 20px rgba(45, 36, 32, 0.08); /* 中浮起:悬浮面板 */ +--elev-float: 0 8px 32px rgba(45, 36, 32, 0.12); /* 高浮起:弹窗、模态 */ + +--shadow-accent: 0 4px 14px rgba(224, 122, 95, 0.25); /* 强调色阴影 */ +--shadow-accent-hover: 0 6px 20px rgba(224, 122, 95, 0.35); +--shadow-input-focus: 0 0 0 3px rgba(224, 122, 95, 0.2); + +--focus-ring: 0 0 0 3px rgba(224, 122, 95, 0.45); /* 焦点环 */ +``` + +--- + +### 2.6 运动与动画 + +```css +--motion-fast: 150ms; /* 快速过渡:按钮、颜色变化 */ +--motion-base: 250ms; /* 基准动画 */ +--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); /* 弹性缓动:悬停、缩放 */ +--ease-standard: cubic-bezier(0.2, 0, 0, 1); /* 标准缓动:位移、透明度 */ +``` + +**动画系统:** +```css +/* 页面进入动画 */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} +.anim-fade { animation: fadeIn 0.5s var(--ease-standard) both; } + +@keyframes slideUp { + from { opacity: 0; transform: translateY(40px); } + to { opacity: 1; transform: translateY(0); } +} +.anim-slide { animation: slideUp 0.6s var(--ease-bounce) both; } + +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} +.anim-scale { animation: scaleIn 0.4s var(--ease-bounce) both; } + +/* 装饰浮动动画 */ +@keyframes float { + 0%, 100% { transform: translateY(0) rotate(0deg); } + 50% { transform: translateY(-10px) rotate(5deg); } +} +``` + +**动画延迟阶梯(用于列表/卡片错落进入):** +- 第 1 项:`animation-delay: 0.1s` +- 第 2 项:`animation-delay: 0.15s` +- 第 3 项:`animation-delay: 0.2s` +- 递增 0.05s + +--- + +### 2.7 布局常量 + +```css +--container-max: 390px; /* 手机最大内容宽度 */ +--safe-top: 54px; /* iOS 顶部安全区(含 Dynamic Island) */ +--safe-bottom: 34px; /* iOS 底部安全区(含 Home Indicator) */ +--tab-height: 56px; /* 底部 Tab 栏高度 */ +--touch-min: 44px; /* 最小触控区域(WCAG 2.5.8) */ +``` + +--- + +## 3. 组件规范 + +### 3.1 iOS 设备框架 + +手机端页面以 iPhone 15 Pro 为基准: + +``` +视口: 390 × 844 px +Dynamic Island: 居中, 126×36px, 黑色圆角矩形, top:12px +状态栏: 高度 54px (--safe-top), 包含时间(左)、信号/电池图标(右) +Home Indicator: 底部居中, 134×5px, 半透明, 距底部 8px +``` + +**实现要点:** +- 每个手机屏幕页面固定 `width: 390px; height: 844px; overflow: hidden` +- 内容区域上留 `--safe-top`,下留 `--tab-height + --safe-bottom` +- Dynamic Island 使用绝对定位 + `z-index: 200` + +--- + +### 3.2 状态栏 (Status Bar) + +```html +
+ 9:41 +
+ +
+
+``` + +样式: +- `display: flex; justify-content: space-between; align-items: center` +- `padding: 14px 28px 0; height: var(--safe-top)` +- `font-size: var(--text-sm); font-weight: 600` +- 时间使用 `font-variant-numeric: tabular-nums` 保证等宽数字 + +--- + +### 3.3 底部 Tab 栏 (Bottom Tab Bar) + +```html + +``` + +样式规则: +``` +位置: absolute, bottom:0, width:100% +高度: calc(var(--tab-height) + var(--safe-bottom)) = 90px +背景: var(--surface) +上边框: 1px solid var(--border-soft) +布局: flex, justify-content: space-around +``` + +**Tab Item 样式:** +- 默认态:`color: var(--muted)` +- 选中态:`color: var(--accent)`,添加 `.active` 类 +- 图标尺寸:24×24px +- 文字:`font-size: var(--text-xs)` (11px) +- 最小触控区域:44px + +**中间「写日记」按钮特殊处理:** +- 使用独立圆形按钮,向上突出 tab 栏 +- `width: 44px; height: 44px; border-radius: 50%` +- `background: var(--accent)` +- `margin-top: -16px`(向上突出) +- 添加 `box-shadow: 0 4px 12px var(--shadow-accent)` 增强浮起感 +- 图标颜色:`var(--accent-on)`(即背景色上方的文字色) + +--- + +### 3.4 按钮 (Buttons) + +#### 主要按钮 (Primary) + +```html + + + +``` + +样式: +``` +padding: 14px 28px (标准) / 16px 48px (大型) +border-radius: var(--radius-pill) = 9999px +background: var(--accent) +color: var(--accent-on) +font-family: var(--font-display) +font-size: var(--text-md) / var(--text-lg) +font-weight: 600-700 +min-height: 44px (满足触控要求) +box-shadow: 0 4px 14px var(--shadow-accent) +transition: all var(--motion-fast) +``` + +交互状态: +| 状态 | 样式 | +|------|------| +| Default | `background: var(--accent)` | +| Hover | `background: var(--accent-hover); transform: translateY(-1px ~ -2px); box-shadow: 增大` | +| Active | `background: var(--accent-active); transform: scale(0.97 ~ 0.98)` | +| Focus | `box-shadow: var(--focus-ring)` | +| Disabled | `opacity: 0.5; cursor: not-allowed` | + +#### 次要按钮 (Secondary) + +``` +background: var(--surface) +color: var(--fg) +border: 1.5px solid var(--border) +Hover → border-color: var(--accent); color: var(--accent) +``` + +#### 幽灵按钮 (Ghost) + +``` +background: transparent +color: var(--muted) +Hover → color: var(--accent) +``` + +--- + +### 3.5 卡片 (Cards) + +#### 标准卡片 + +```html +
+ +
+``` + +``` +background: var(--surface) +border-radius: var(--radius-md) = 16px +padding: var(--space-5) = 20px +box-shadow: var(--elev-soft) +border: 1px solid var(--border-soft) +``` + +#### 日记条目卡片 (Entry Card) + +```html +
+
🌸
+ +
😊
+
+``` + +布局: +``` +display: flex; gap: var(--space-4) = 16px +预览缩略图: 72×72px, border-radius: 10px, flex-shrink: 0 +标题: font-family: var(--font-display); font-weight: 600; 单行截断 +摘要: 两行截断 (-webkit-line-clamp: 2) +交互: Hover → translateY(-1px); Active → scale(0.98) +``` + +#### 今日日记卡片 (Today Card) — 渐变特殊卡片 + +``` +background: linear-gradient(135deg, var(--accent) 0%, var(--tertiary) 100%) +border-radius: var(--radius-lg) = 22px +padding: var(--space-6) = 24px +``` + +装饰圆形: +- 右上角:120×120px, `rgba(255,255,255,0.12)`, 位置 `top: -30px; right: -30px` +- 左下角:80×80px, `rgba(255,255,255,0.08)`, 位置 `bottom: -20px; left: 40px` + +写按钮(浮动在右下角): +``` +position: absolute; bottom: 20px; right: 20px +width: 48px; height: 48px; border-radius: 50% +background: white; box-shadow: 0 4px 12px rgba(0,0,0,0.1) +Hover → scale(1.1) +``` + +--- + +### 3.6 输入框 (Input Fields) + +```html +
+ + +
+``` + +样式: +``` +height: 50px +padding: 0 16px 0 46px (左侧留出图标空间) +border: 1.5px solid var(--border) +border-radius: var(--radius-pill) +font-family: var(--font-body); font-size: var(--text-base) +background: var(--surface) +transition: border-color var(--motion-fast) +``` + +交互状态: +| 状态 | 样式 | +|------|------| +| Default | `border: 1.5px solid var(--border)` | +| Focus | `border-color: var(--accent); box-shadow: var(--shadow-input-focus)` | +| Placeholder | `color: var(--meta)` | + +**带操作按钮的输入框(验证码):** +- 输入框 `padding-right: 110px` +- 操作按钮:`position: absolute; right: 6px`, `color: var(--accent)` +- 倒计时态:`opacity: 0.6; disabled` + +**密码切换按钮:** +- `position: absolute; right: 14px` +- 触控区域:`min-height: 44px; min-width: 44px`(满足 WCAG) + +--- + +### 3.7 标签/Chip + +```html + + +``` + +``` +padding: 6px 14px +border-radius: var(--radius-pill) +font-size: var(--text-sm) = 13px; font-weight: 500 +background: var(--surface); color: var(--fg-2); border: 1px solid var(--border) +Active → background: var(--accent); color: var(--accent-on); border-color: var(--accent) +``` + +--- + +### 3.8 心情选择器 (Mood Selector) + +```html +
+ + +
+``` + +布局: +``` +display: flex; justify-content: space-between +每个选项: display: flex; flex-direction: column; align-items: center; gap: 4px +最小触控: min-width: 44px; min-height: 44px +``` + +状态: +- Default: `background: none` +- Hover: `background: var(--surface-warm)` +- Selected: `background: var(--surface-warm); .label { color: var(--accent); font-weight: 600 }` + +--- + +### 3.9 社交登录按钮 + +```html + +``` + +``` +尺寸: 56×56px, border-radius: 50% +间距: gap: var(--space-5) = 20px +Hover → translateY(-2px); box-shadow: var(--elev-soft) +Active → scale(0.95) +``` + +品牌色: +- 微信:`background: #07C160; border-color: #07C160`,Hover → `#06AD56` +- Apple:`background: #1D1D1F; border-color: #1D1D1F`,Hover → `#333` +- Google:`background: var(--surface)`(白色背景) + +--- + +### 3.10 顶部导航栏 (Top Nav) + +``` +display: flex; align-items: center; justify-content: space-between +padding: var(--space-4) var(--space-5) +标题: font-family: var(--font-display); font-size: var(--text-xl); font-weight: 700 +操作按钮: width: var(--touch-min) = 44px; height: 44px; border-radius: pill +背景: var(--surface) +``` + +--- + +### 3.11 日期/时间条 (Date Strip) — 编辑器内 + +``` +position: absolute; top: calc(var(--safe-top) + 44px) +display: flex; justify-content: space-between; align-items: center +padding: var(--space-2) var(--space-4) +background: var(--surface) +``` + +--- + +### 3.12 月历导航 (Month Navigation) + +```html +
+
2026年5月
+
+ + +
+
+``` + +导航按钮: +``` +min-width: 44px; min-height: 44px +border-radius: 50%; border: 1.5px solid var(--border) +background: var(--surface) +Hover → border-color: var(--accent); color: var(--accent) +``` + +--- + +### 3.13 视图切换 (View Toggle) + +```html +
+ + +
+``` + +``` +display: flex; background: var(--surface) +border-radius: var(--radius-pill); padding: 3px +border: 1px solid var(--border-soft) +Active button: background: var(--accent); color: var(--accent-on); box-shadow: var(--elev-soft) +``` + +--- + +## 4. 页面结构与布局 + +### 4.1 手机端页面(390×844) + +每个手机屏幕的通用结构: + +```html + + +
+ + +
+ +
+ + + + + +
+ +``` + +**内容区域计算:** +- 可用高度:844 - 54 (safe-top) - 56 (tab-height) - 34 (safe-bottom) = 700px +- 水平内边距:`var(--space-5)` = 20px + +--- + +### 4.2 启动页 (Splash) + +**布局:** 全屏居中 +- 背景:渐变 `linear-gradient(165deg, var(--bg) 0%, var(--tertiary-soft) 40%, accent混合50%透明 100%)` +- App 图标:120×120px, `border-radius: 32px`, 渐变背景 + 白色半透明装饰圆 +- 品牌名:42px, `font-weight: 700`, `letter-spacing: 2px` +- 副标题:`var(--text-md)`, `var(--muted)` +- 进入按钮:底部 `calc(var(--safe-bottom) + 40px)` 处 +- 装饰元素:浮动星星/圆圈,`opacity: 0.08~0.25`, `animation: float 4~6s` + +--- + +### 4.3 引导页 (Onboarding) + +- 3 步水平滑动 +- 每步包含:插图区域 + 标题 + 描述 + 进度指示器 +- 底部:跳过/下一步/开始使用按钮 + +--- + +### 4.4 首页日记流 (Home Daily) + +**内容层级(从上到下):** +1. 问候语区域(日期 + 问候 + 搜索按钮) +2. 连续记录徽章(streak badge) +3. 心情选择区域(卡片包裹) +4. 今日日记卡片(渐变特殊卡片 + 写按钮) +5. 快速统计(3 列统计卡片) +6. 最近记录标题(标题 + "查看全部"链接) +7. 日记条目卡片列表 + +**统计卡片布局:** +``` +display: flex; gap: var(--space-3) +每个 stat-card: flex:1; text-align:center +数字: var(--text-2xl) = 30px; font-weight:700 +标签: var(--text-xs) = 11px; color: var(--muted) +``` + +--- + +### 4.5 手账编辑器 (Editor) + +**布局层级:** +1. 顶部工具栏:返回/日期/保存按钮 +2. 日期 & 心情条 +3. 画布区域(可滚动) +4. 底部工具面板(文字/贴纸/画笔/照片/模板 Tab 切换) + +**工具栏:** +``` +height: calc(var(--safe-top) + 44px) +background: var(--surface); border-bottom: 1px solid var(--border-soft) +按钮: min-width/min-height: 36px; border-radius: pill +保存按钮: background: var(--accent); color: var(--accent-on) +``` + +--- + +### 4.6 日历视图 (Calendar) + +**内容层级:** +1. 月份标题 + 前后导航 +2. 视图切换(月/周) +3. 星期行(日 一 二 三 四 五 六) +4. 日期网格(7 列 grid) +5. 心情色彩标记(每个有日记的日期显示对应心情颜色) +6. 时间轴/日记详情 + +**日历网格:** +``` +display: grid; grid-template-columns: repeat(7, 1fr) +日期单元格: aspect-ratio:1; display:flex; align-items:center; justify-content:center +当前日期: background:var(--accent); color:var(--accent-on); border-radius:50% +有日记日期: 底部小圆点标记,颜色对应心情 +``` + +--- + +### 4.7 登录/注册 (Login) + +**布局:** 上下分区 +- 上部:品牌区域(Logo + 名称 + 标语),带装饰元素和渐变背景 +- 下部:表单区域(圆角顶部覆盖),包含输入框和按钮 + +**表单切换逻辑:** +- 默认显示登录态(手机号 + 验证码 + 登录按钮) +- 点击「立即注册」切换为注册态(增加昵称、密码、协议勾选) +- 通过 `.is-register` 类控制字段显隐 + +**社交登录区域:** +- 分隔线:`display:flex; align-items:center; gap`, 两侧 `flex:1` 的 `1px` 横线 +- 三个圆形社交按钮:微信、Apple、Google + +--- + +### 4.8 发现页 (Discover) + +**内容层级:** +1. 搜索栏 +2. 热门话题标签(横向滚动 chips) +3. 精选模板(横向滚动卡片) +4. 达人日记(瀑布流/列表) + +--- + +### 4.9 搜索结果页 (Search) + +**内容层级:** +1. 搜索输入框(带返回按钮) +2. 搜索历史标签 +3. 结果分类筛选 tabs +4. 搜索结果列表 + +--- + +## 5. 多平台适配规则 + +### 5.1 平板端 (iPad 1024×768) + +**布局差异:** +- 使用侧边栏导航替代底部 Tab 栏 +- 内容区域采用双列布局 +- 编辑器使用分栏模式(左侧画布 + 右侧工具面板) +- 日历采用月历 + 时间轴双栏并排 + +**关键适配代码:** +```css +@media (min-width: 768px) { + /* 侧边栏导航 */ + .sidebar { width: 240px; position: fixed; left: 0; top: 0; bottom: 0; } + .main-content { margin-left: 240px; } + + /* 双列网格 */ + .content-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; } + + /* 编辑器分栏 */ + .editor-layout { display: grid; grid-template-columns: 1fr 320px; } +} +``` + +### 5.2 桌面端 (1440×900) + +**布局差异:** +- 固定侧边栏(宽 280px)+ 多列内容区 +- 编辑器全宽画布 + 右侧属性面板 +- 日历三栏布局(统计 + 月历 + 日记详情) +- 更高的信息密度:更多内容可见区域 + +**关键适配代码:** +```css +@media (min-width: 1024px) { + .sidebar { width: 280px; } + .content-grid { grid-template-columns: repeat(3, 1fr); } + .editor-layout { grid-template-columns: 1fr 380px; } + .calendar-layout { grid-template-columns: 280px 1fr 380px; } +} +``` + +--- + +## 6. 主题切换系统 + +### 工作原理 + +1. **默认主题**:暖阳(Warm Sun),`:root` 直接定义 +2. **松风主题**:通过 `data-theme="pine"` 属性切换 +3. **暗色模式**:`@media (prefers-color-scheme: dark)` 自动响应 +4. **持久化**:`localStorage.setItem('warmnotes-theme', themeId)` +5. **跨 Tab 同步**:`window.addEventListener('storage', ...)` 监听变化 + +### 切换按钮 + +- 固定位置:`position:fixed; top:8px; right:8px; z-index:10000` +- 外观:圆角胶囊,包含色点 + 主题名称 +- 点击循环切换:暖阳 → 松风 → 暖阳 + +--- + +## 7. 无障碍 (Accessibility) + +### 已实现的 WCAG 要点 + +1. **触控区域**:所有交互元素 `min-height: 44px`(满足 WCAG 2.5.8) +2. **焦点环**:`box-shadow: var(--focus-ring)` 统一在 `:focus-visible` 显示 +3. **ARIA 标签**:所有按钮带 `aria-label`,Tab 栏使用 `role="tablist"` / `role="tab"` / `aria-selected` +4. **语义 HTML**:`