diff --git a/.env.android b/.env.android new file mode 100644 index 00000000..6be1ad86 --- /dev/null +++ b/.env.android @@ -0,0 +1,35 @@ +# Android 特定配置 +# PawSync Pro - 爪爪连心 + +# Android 包名 +VITE_ANDROID_PACKAGE_NAME=com.pawsync.pro + +# Android 版本号 +VITE_ANDROID_VERSION_CODE=1 + +# Android 版本名称 +VITE_ANDROID_VERSION_NAME=1.0.0 + +# Android 最低 SDK 版本 +VITE_ANDROID_MIN_SDK_VERSION=22 + +# Android 目标 SDK 版本 +VITE_ANDROID_TARGET_SDK_VERSION=34 + +# 是否启用 Android 原生功能 +VITE_ENABLE_NATIVE_FEATURES=true + +# 是否启用 Android 触觉反馈 +VITE_ENABLE_ANDROID_HAPTICS=true + +# 是否启用 Android 本地通知 +VITE_ENABLE_ANDROID_LOCAL_NOTIFICATIONS=true + +# 是否启用 Android 推送通知 +VITE_ENABLE_ANDROID_PUSH_NOTIFICATIONS=true + +# Android 深链接 Scheme +VITE_ANDROID_DEEP_LINK_SCHEME=com.pawsync.pro + +# Android App Links Host +VITE_ANDROID_APP_LINKS_HOST=pawsync.com \ No newline at end of file diff --git a/.env.development b/.env.development index f1cb3f78..426771bb 100644 --- a/.env.development +++ b/.env.development @@ -1,14 +1,23 @@ -# Development Environment Configuration # 开发环境配置 +# PawSync Pro - 爪爪连心 +# API 地址配置 VITE_API_URL=http://localhost:3000/api + +# 环境标识 VITE_ENV=development + +# 是否启用模拟数据(开发环境默认启用) VITE_ENABLE_MOCK_DATA=true -# Backend Server -VITE_BACKEND_URL=http://localhost:3000 -VITE_WS_URL=ws://localhost:3000 +# 应用版本 +VITE_APP_VERSION=1.0.0 + +# 是否启用调试模式 +VITE_DEBUG_MODE=true + +# 日志级别 +VITE_LOG_LEVEL=debug -# Debug Features -VITE_ENABLE_DEBUG_LOG=true -VITE_ENABLE_PERFORMANCE_LOG=false +# Android 模拟器地址(用于本地调试) +VITE_ANDROID_EMULATOR_URL=http://10.0.2.2:3000/api \ No newline at end of file diff --git a/.trae/documents/design-system.md b/.trae/documents/design-system.md new file mode 100644 index 00000000..8f246cfc --- /dev/null +++ b/.trae/documents/design-system.md @@ -0,0 +1,374 @@ +# PawSync Pro - 设计系统规范 v2.0 + +## 1. 设计理念 + +### 视觉风格 +- **风格定位**: 温暖、精致、专业、高端 +- **设计语言**: 柔和有机形态 + 现代简洁卡片 +- **情感传达**: 温馨陪伴、专业守护、智能科技 + +### 设计原则 +1. **温暖优先**: 使用暖色调营造家的感觉 +2. **信息清晰**: 数据可视化直观易懂 +3. **交互流畅**: 微动画提升体验品质 +4. **一致性**: 全局设计元素统一 + +--- + +## 2. 色彩系统 + +### 主色调 +```css +--color-primary: #F5A623; /* 主橙色 - 按钮、强调 */ +--color-primary-light: #FFB84D; /* 浅橙色 - 悬停状态 */ +--color-primary-dark: #E09000; /* 深橙色 - 按下状态 */ +``` + +### 背景色 +```css +--color-bg-primary: #FAF7F2; /* 主背景 - 暖米色 */ +--color-bg-secondary: #FFFFFF; /* 卡片背景 - 纯白 */ +--color-bg-tertiary: #F5F0E8; /* 次级背景 - 浅暖灰 */ +``` + +### 功能色 +```css +--color-success: #4CAF50; /* 成功/正常 - 绿色 */ +--color-success-bg: #E8F5E9; /* 成功背景 */ +--color-warning: #FF9800; /* 警告 - 橙色 */ +--color-error: #F44336; /* 错误 - 红色 */ +--color-info: #2196F3; /* 信息 - 蓝色 */ +``` + +### 文字色 +```css +--color-text-primary: #333333; /* 主文字 - 深灰 */ +--color-text-secondary: #666666; /* 次级文字 - 中灰 */ +--color-text-tertiary: #999999; /* 辅助文字 - 浅灰 */ +--color-text-white: #FFFFFF; /* 白文字 */ +``` + +### 设备状态色 +```css +--color-device-online: #4CAF50; /* 在线 - 绿色 */ +--color-device-offline: #999999; /* 离线 - 灰色 */ +--color-device-warning: #FF9800; /* 警告 - 橙色 */ +``` + +--- + +## 3. 字体系统 + +### 字体家族 +```css +--font-family-primary: 'PingFang SC', 'Helvetica Neue', Arial, sans-serif; +--font-family-number: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif; +``` + +### 字号规范 +| 级别 | 大小 | 字重 | 用途 | +|------|------|------|------| +| H1 | 24px | 700 | 页面标题 | +| H2 | 20px | 600 | 区块标题 | +| H3 | 18px | 600 | 卡片标题 | +| H4 | 16px | 500 | 小标题 | +| Body | 14px | 400 | 正文 | +| Caption | 12px | 400 | 辅助说明 | +| Small | 10px | 400 | 标签 | + +### 数字展示 +```css +/* 大数字 - 统计数据 */ +--font-number-large: 32px/700; +/* 中数字 - 卡片数据 */ +--font-number-medium: 24px/600; +/* 小数字 - 辅助数据 */ +--font-number-small: 16px/500; +``` + +--- + +## 4. 间距系统 + +### 基础单位 +```css +--spacing-unit: 4px; +``` + +### 间距规范 +| Token | 值 | 用途 | +|-------|-----|------| +| xs | 4px | 图标与文字间距 | +| sm | 8px | 紧凑元素间距 | +| md | 12px | 标准元素间距 | +| lg | 16px | 区块内间距 | +| xl | 20px | 区块间间距 | +| 2xl | 24px | 大区块间距 | +| 3xl | 32px | 页面边距 | + +--- + +## 5. 圆角系统 + +```css +--radius-sm: 8px; /* 小按钮、标签 */ +--radius-md: 12px; /* 卡片、输入框 */ +--radius-lg: 16px; /* 大卡片、模态框 */ +--radius-xl: 20px; /* 特殊卡片 */ +--radius-full: 9999px; /* 圆形、胶囊 */ +``` + +--- + +## 6. 阴影系统 + +```css +/* 轻微阴影 - 卡片默认 */ +--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.04); + +/* 标准阴影 - 卡片悬停 */ +--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06); + +/* 强调阴影 - 浮动元素 */ +--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.08); + +/* 特殊阴影 - FAB按钮 */ +--shadow-fab: 0 4px 12px rgba(245, 166, 35, 0.4); +``` + +--- + +## 7. 组件规范 + +### 7.1 宠物信息卡片 +``` +┌─────────────────────────────────────┐ +│ ┌────┐ JOJO ♂ │ +│ │ 🐶 │ 柯基犬 · 2岁 │ +│ └────┘ 🟢 活力充沛 │ +└─────────────────────────────────────┘ +``` +- 头像: 56px 圆形 +- 名称: 20px 粗体 +- 品种年龄: 14px 常规 +- 状态标签: 12px 绿色背景胶囊 + +### 7.2 功能网格卡片 +``` +┌──────────┐ +│ 🔵 │ 图标: 48px 圆形背景 +│ AI问诊 │ 标题: 14px 中等 +│ 智能问答 │ 描述: 12px 浅灰 +└──────────┘ +``` +- 卡片: 圆角 16px, 白色背景 +- 图标区: 48px 圆形, 浅色背景 +- 间距: 8px 图标与文字 + +### 7.3 设备卡片 +``` +┌─────────────────┐ +│ 📷 │ 设备图: 80px +│ ● 在线 │ 状态点: 6px +│ JOJO的碗 │ 名称: 14px +│ 🔋 85% │ 电量: 12px +└─────────────────┘ +``` +- 卡片: 圆角 12px +- 状态点: 6px 圆形, 绿色 +- 电量图标: 12px + +### 7.4 数据卡片 +``` +┌─────────────────┐ +│ 🍽️ 进食次数 │ 图标+标题 +│ │ +│ 8 次 │ 数字: 32px 粗体 +│ ● 正常 │ 状态: 12px +└─────────────────┘ +``` +- 数字: 32px 粗体, DIN字体 +- 单位: 14px 常规 +- 状态: 12px 带色点 + +### 7.5 记录时间线 +``` +●──────────────┐ +│ 08:30 │ +│ 🍽️ 喂食 │ +│ 喂食120g狗粮 │ +└──────────────┘ +``` +- 时间点: 10px 彩色圆点 +- 时间: 12px 浅灰 +- 标题: 16px 中等 +- 描述: 14px 常规 + +### 7.6 底部导航 +``` +┌────┬────┬────┬────┬────┐ +│ 🏠 │ 📷 │ ➕ │ ❤️ │ 👤 │ +│首页│设备│记录│健康│我的│ +└────┴────┴────┴────┴────┘ +``` +- 高度: 64px + safe-area +- 图标: 24px +- 文字: 10px +- 选中: 橙色 +- FAB: 56px 圆形, 橙色, 居中 + +--- + +## 8. 页面布局规范 + +### 8.1 首页结构 +``` +┌─────────────────────────────┐ +│ 状态栏 │ +├─────────────────────────────┤ +│ 宠物信息卡片 │ ← 固定顶部 +├─────────────────────────────┤ +│ 宠物3D展示区 │ ← 可滑动 +├─────────────────────────────┤ +│ 功能网格 (4列) │ +├─────────────────────────────┤ +│ 我的设备 (横向滚动) │ +├─────────────────────────────┤ +│ 今日饮食数据 (4列) │ +├─────────────────────────────┤ +│ 其他内容... │ +└─────────────────────────────┘ +│ 底部导航栏 │ ← 固定底部 +└─────────────────────────────┘ +``` + +### 8.2 记录页面结构 +``` +┌─────────────────────────────┐ +│ 标题: 记录 │ +│ 副标题: 记录每一次陪伴... │ +├─────────────────────────────┤ +│ 分类筛选: 全部|喂食|饮水|... │ ← 横向滚动 +├─────────────────────────────┤ +│ 日期选择器 │ +├─────────────────────────────┤ +│ 时间线列表 │ ← 主内容区 +│ ● 喂食 08:30 │ +│ ● 饮水 10:15 │ +│ ... │ +├─────────────────────────────┤ +│ 历史日期汇总 │ ← 可折叠 +└─────────────────────────────┘ +``` + +--- + +## 9. 动画规范 + +### 9.1 过渡动画 +```css +/* 标准过渡 */ +--transition-fast: 150ms ease; +--transition-normal: 250ms ease; +--transition-slow: 350ms ease; + +/* 弹性过渡 */ +--transition-bounce: 300ms cubic-bezier(0.34, 1.56, 0.64, 1); +``` + +### 9.2 交互动画 +- 按钮按下: scale(0.96), 150ms +- 卡片悬停: translateY(-2px), shadow-lg, 250ms +- 页面切换: fade + slide, 300ms +- 数字变化: count-up, 500ms +- 加载状态: pulse/skeleton, 无限循环 + +--- + +## 10. 图标规范 + +### 图标尺寸 +| 用途 | 尺寸 | 描边宽度 | +|------|------|----------| +| 导航图标 | 24px | 2px | +| 功能图标 | 20px | 2px | +| 列表图标 | 16px | 1.5px | +| 状态图标 | 12px | 1.5px | + +### 图标风格 +- 线性图标为主 +- 圆角端点 (round) +- 统一线宽 +- 简洁几何形态 + +--- + +## 11. 响应式断点 + +```css +/* 移动端优先 */ +--screen-sm: 375px; /* 小屏手机 */ +--screen-md: 414px; /* 大屏手机 */ +--screen-lg: 768px; /* 平板 */ +--screen-xl: 1024px; /* 大平板/小屏桌面 */ +``` + +--- + +## 12. 功能入口映射 + +### 首页功能入口 +| 功能 | 目标页面 | 图标 | 描述 | +|------|----------|------|------| +| AI问诊 | AIConsultantPage | Stethoscope | 智能问答 | +| 健康报告 | HealthReportPage | FileText | 今日生成 | +| 饮食建议 | DietAdvicePage | Utensils | 科学喂养 | +| 宠物档案 | PetProfilePage | PawPrint | 记录成长 | + +### 设备入口 +| 功能 | 目标页面 | 说明 | +|------|----------|------| +| 智能项圈 | CameraPage | 监控设备 | +| 喂食器 | CameraPage | 监控设备 | +| 饮水机 | CameraPage | 监控设备 | +| 添加设备 | DevicePairing | 设备配对 | + +### 底部导航 +| Tab | 页面 | 图标 | +|-----|------|------| +| 首页 | HomePage | Home | +| 设备 | DevicesPage | Camera | +| 记录 | RecordsPage | PlusCircle (FAB) | +| 健康 | HealthPage | Heart | +| 我的 | ProfilePage | User | + +--- + +## 13. 数据展示规范 + +### 统计数据展示 +``` +数值 + 单位 + 状态 +例: 8 次 ● 正常 +``` + +### 时间展示 +``` +今天: "今天" +昨天: "昨天" +本周: "周一" ~ "周日" +其他: "5月20日" +``` + +### 状态标签 +``` +正常: 绿色圆点 + "正常" +异常: 橙色圆点 + "注意" +警告: 红色圆点 + "异常" +``` + +--- + +*文档版本: v2.0* +*更新日期: 2026-06-12* +*设计团队: PawSync Pro Design Team* diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index db079c8e..9c0556e8 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,13 +9,20 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { + implementation project(':capacitor-action-sheet') + implementation project(':capacitor-app') + implementation project(':capacitor-app-launcher') implementation project(':capacitor-camera') + implementation project(':capacitor-dialog') + implementation project(':capacitor-geolocation') implementation project(':capacitor-haptics') implementation project(':capacitor-keyboard') implementation project(':capacitor-local-notifications') implementation project(':capacitor-preferences') implementation project(':capacitor-push-notifications') implementation project(':capacitor-share') + implementation project(':capacitor-splash-screen') + implementation project(':capacitor-status-bar') } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index a4778eed..1a3209fc 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -2,9 +2,24 @@ include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') +include ':capacitor-action-sheet' +project(':capacitor-action-sheet').projectDir = new File('../node_modules/@capacitor/action-sheet/android') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + +include ':capacitor-app-launcher' +project(':capacitor-app-launcher').projectDir = new File('../node_modules/@capacitor/app-launcher/android') + include ':capacitor-camera' project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android') +include ':capacitor-dialog' +project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android') + +include ':capacitor-geolocation' +project(':capacitor-geolocation').projectDir = new File('../node_modules/@capacitor/geolocation/android') + include ':capacitor-haptics' project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') @@ -22,3 +37,9 @@ project(':capacitor-push-notifications').projectDir = new File('../node_modules/ include ':capacitor-share' project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android') + +include ':capacitor-splash-screen' +project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') diff --git a/package-lock.json b/package-lock.json index 223a6360..b4b91776 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,23 @@ "name": "爪爪连心❤️", "version": "1.0.0", "dependencies": { + "@capacitor/action-sheet": "^8.1.1", "@capacitor/android": "^8.3.4", + "@capacitor/app": "^8.1.0", + "@capacitor/app-launcher": "^8.0.1", "@capacitor/camera": "^8.0.0", "@capacitor/cli": "^8.3.4", "@capacitor/core": "^8.3.4", + "@capacitor/dialog": "^8.0.1", + "@capacitor/geolocation": "^8.2.0", "@capacitor/haptics": "^8.0.2", "@capacitor/keyboard": "^8.0.3", "@capacitor/local-notifications": "^8.2.0", "@capacitor/preferences": "^8.0.0", "@capacitor/push-notifications": "^8.1.1", "@capacitor/share": "^8.0.1", + "@capacitor/splash-screen": "^8.0.1", + "@capacitor/status-bar": "^8.0.2", "@emotion/is-prop-valid": "^1.4.0", "clsx": "^2.1.1", "framer-motion": "^12.40.0", @@ -47,6 +54,7 @@ "jsdom": "^29.1.1", "postcss": "^8.5.3", "tailwindcss": "^3.4.14", + "terser": "^5.48.0", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", "vite": "^6.3.5", @@ -440,6 +448,15 @@ "specificity": "bin/cli.js" } }, + "node_modules/@capacitor/action-sheet": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@capacitor/action-sheet/-/action-sheet-8.1.1.tgz", + "integrity": "sha512-yHuGSR/FS2dDr9oBmy+Ql+y7/i5XsUAlENx5oRND5NtzsNLLiFxpNPGLWV27aU9uf9ICjI92VzH93Zh0g2FL1A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/android": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.4.tgz", @@ -449,6 +466,24 @@ "@capacitor/core": "^8.3.0" } }, + "node_modules/@capacitor/app": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.0.tgz", + "integrity": "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/app-launcher": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/app-launcher/-/app-launcher-8.0.1.tgz", + "integrity": "sha512-23D8zi74sn7kxvISix8qYwgqdxGJN+4NImcNGvHen98LB1zb4eZfkJvFvp7pwntxGw4OjIE7yuf4wbzZxQHpog==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/camera": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-8.2.0.tgz", @@ -520,6 +555,27 @@ "tslib": "^2.1.0" } }, + "node_modules/@capacitor/dialog": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/dialog/-/dialog-8.0.1.tgz", + "integrity": "sha512-Dq0mZBsd++7CvPvpD2gfbUB44k/zCoe5K9IgYmMImQ4Yphu4/MxLVpFDFAqSya011UBBTGPrUQGVacIW3jDNkw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/geolocation": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.2.0.tgz", + "integrity": "sha512-N29QcoIPmme0xSxRkm7+3hjoHp6mBAOarxecvtCCZKyOBeKiJsFUq981cezg2XWBa6fhCXJMCCjQPngKK/dIag==", + "license": "MIT", + "dependencies": { + "@capacitor/synapse": "^1.0.4" + }, + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/haptics": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz", @@ -574,6 +630,30 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/splash-screen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-8.0.1.tgz", + "integrity": "sha512-c/ew/Z3eA7z8l06WoRAtzVF16VwYYrExmHmfGq1Cg675pVzaC/yuucB8/1xG1vhEfnW4fZ1KhSf/kzR1RiVYgg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.2.tgz", + "integrity": "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/synapse": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", + "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", + "license": "ISC" + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -1595,6 +1675,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -3168,6 +3259,13 @@ "node": "*" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -6012,6 +6110,16 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6022,6 +6130,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -6235,6 +6354,32 @@ "node": ">=18" } }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", diff --git a/package.json b/package.json index 4b2ee205..e08db7d1 100644 --- a/package.json +++ b/package.json @@ -7,25 +7,43 @@ "type": "module", "scripts": { "dev": "vite", + "dev:android": "vite --mode android", "build": "vite build", + "build:android": "vite build --mode android && npx cap sync android", + "build:production": "vite build --mode production", "lint": "eslint .", "preview": "vite preview", "check": "tsc -b --noEmit", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "cap:init": "npx cap init", + "cap:add:android": "npx cap add android", + "cap:sync": "npx cap sync", + "cap:sync:android": "npx cap sync android", + "cap:open:android": "npx cap open android", + "android:run": "npm run build:android && npx cap run android", + "android:build:apk": "npm run build:android && cd android && ./gradlew assembleDebug", + "android:build:release": "npm run build:android && cd android && ./gradlew assembleRelease" }, "dependencies": { + "@capacitor/action-sheet": "^8.1.1", "@capacitor/android": "^8.3.4", + "@capacitor/app": "^8.1.0", + "@capacitor/app-launcher": "^8.0.1", "@capacitor/camera": "^8.0.0", "@capacitor/cli": "^8.3.4", "@capacitor/core": "^8.3.4", + "@capacitor/dialog": "^8.0.1", + "@capacitor/geolocation": "^8.2.0", "@capacitor/haptics": "^8.0.2", "@capacitor/keyboard": "^8.0.3", "@capacitor/local-notifications": "^8.2.0", "@capacitor/preferences": "^8.0.0", "@capacitor/push-notifications": "^8.1.1", "@capacitor/share": "^8.0.1", + "@capacitor/splash-screen": "^8.0.1", + "@capacitor/status-bar": "^8.0.2", "@emotion/is-prop-valid": "^1.4.0", "clsx": "^2.1.1", "framer-motion": "^12.40.0", @@ -55,6 +73,7 @@ "jsdom": "^29.1.1", "postcss": "^8.5.3", "tailwindcss": "^3.4.14", + "terser": "^5.48.0", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", "vite": "^6.3.5", diff --git a/src/App.tsx b/src/App.tsx index ac5399db..4aa38eb3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,9 @@ import { useAppStore } from './store/appStore'; import { PawPrint } from 'lucide-react'; import { useDeviceCapabilities, applyPerformanceClass } from './utils/performanceDetection'; import { FloatingActionButton } from './components/FloatingActionButton'; +import { initializeAndroidPerformance } from './config/androidPerformance'; +// 引入 Apple 风格全局样式 +import './styles/apple-style.css'; // 懒加载其他页面(性能优化) const TranslatorPage = lazy(() => import('./pages/TranslatorPage')); @@ -37,6 +40,11 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage')); const FavoritesPage = lazy(() => import('./pages/FavoritesPage')); const HelpFeedbackPage = lazy(() => import('./pages/HelpFeedbackPage')); const DeveloperInfoPage = lazy(() => import('./pages/DeveloperInfoPage')); +// 新增页面 +const PetProfilePage = lazy(() => import('./pages/PetProfilePage')); +const DevicesPage = lazy(() => import('./pages/DevicesPage')); +const DietDataPage = lazy(() => import('./pages/DietDataPage')); +const RecordsPage = lazy(() => import('./pages/RecordsPage')); // 页面加载占位符 const PageLoader = () => ( @@ -91,6 +99,9 @@ export default function App() { const capabilities = useDeviceCapabilities(); useEffect(() => { + // 初始化 Android 性能优化 + initializeAndroidPerformance(); + if (!isInitialized) { initializeApp(); } @@ -163,6 +174,17 @@ export default function App() { return ; case 'history': return ; + // 新增页面路由 + case 'pet-profile': + return ; + case 'devices': + return ; + case 'diet-data': + return ; + case 'records': + return ; + case 'diet-advice': + return ; default: return ; } diff --git a/src/components/AddReminderModal.tsx b/src/components/AddReminderModal.tsx index 844cb04e..730535e1 100644 --- a/src/components/AddReminderModal.tsx +++ b/src/components/AddReminderModal.tsx @@ -24,6 +24,16 @@ interface AddReminderModalProps { petBirthday?: string; lastVaccineDate?: string; lastDewormingDate?: string; + editingReminder?: { + id: string; + type: ReminderType; + title: string; + notes?: string; + date: string; + time: string; + repeat: RepeatType; + endDate?: string; + } | null; } const REPEAT_OPTIONS = [ @@ -54,6 +64,7 @@ export const AddReminderModal: React.FC = ({ petBirthday, lastVaccineDate, lastDewormingDate, + editingReminder, }) => { const [selectedType, setSelectedType] = useState('vaccine'); const [title, setTitle] = useState(''); @@ -129,8 +140,21 @@ export const AddReminderModal: React.FC = ({ useEffect(() => { if (isOpen) { generateSmartRecommendations(); + + // 如果是编辑模式,填充表单数据 + if (editingReminder) { + setSelectedType(editingReminder.type); + setTitle(editingReminder.title); + setNotes(editingReminder.notes || ''); + setDate(editingReminder.date); + setTime(editingReminder.time); + setRepeat(editingReminder.repeat); + setEndDate(editingReminder.endDate || ''); + } else { + resetForm(); + } } - }, [isOpen, generateSmartRecommendations]); + }, [isOpen, generateSmartRecommendations, editingReminder]); const applyRecommendation = (rec: { type: ReminderType; suggestedDate: string; suggestedTime: string; reason: string }) => { setSelectedType(rec.type); @@ -217,7 +241,7 @@ export const AddReminderModal: React.FC = ({ }; return ( - +
{errors.pet && (
@@ -436,7 +460,7 @@ export const AddReminderModal: React.FC = ({ onClick={handleSubmit} className="flex-1 px-4 py-3 md:py-2.5 rounded-xl bg-gradient-to-r from-purple-500 to-purple-600 text-white font-medium hover:shadow-lg hover:shadow-purple-500/30 active:from-purple-600 active:to-purple-700 transition-all min-h-[48px] md:min-h-0" > - 添加提醒 + {editingReminder ? '保存修改' : '添加提醒'}
diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 7ca4870b..eb161a50 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -1,199 +1,222 @@ -import React, { memo, useMemo, useCallback, useRef, useEffect, useState } from 'react'; -import { Home, Shield, Sparkles, Camera, User } from 'lucide-react'; - -const debounce = unknown>( - func: T, - wait: number -): ((...args: Parameters) => void) => { - let timeoutId: ReturnType | null = null; - - return (...args: Parameters) => { - if (timeoutId) { - clearTimeout(timeoutId); - } - timeoutId = setTimeout(() => { - func(...args); - timeoutId = null; - }, wait); - }; -}; - -const throttle = unknown>( - func: T, - limit: number -): ((...args: Parameters) => void) => { - let inThrottle = false; - let lastArgs: Parameters | null = null; - - return (...args: Parameters) => { - if (!inThrottle) { - func(...args); - inThrottle = true; - setTimeout(() => { - inThrottle = false; - if (lastArgs) { - func(...lastArgs); - lastArgs = null; - } - }, limit); - } else { - lastArgs = args; - } - }; -}; - -const useVisibilityChange = ( - onVisible?: () => void, - onHidden?: () => void -): boolean => { - const [isVisible, setIsVisible] = useState(!document.hidden); - const onVisibleRef = useRef(onVisible); - const onHiddenRef = useRef(onHidden); - - useEffect(() => { - onVisibleRef.current = onVisible; - onHiddenRef.current = onHidden; - }, [onVisible, onHidden]); - - useEffect(() => { - const handleVisibilityChange = () => { - const visible = !document.hidden; - setIsVisible(visible); - - if (visible) { - onVisibleRef.current?.(); - } else { - onHiddenRef.current?.(); - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, []); - - return isVisible; -}; +// ============================================ +// PawSync Pro - Navigation.tsx +// +// 作者: 带娃的小陈工 +// 日期: 2026-06-12 +// 描述: 固定底部导航栏 - Apple风格设计 +// ============================================ + +import React, { memo, useMemo, useCallback } from 'react'; +import { Home, Camera, PlusCircle, Heart, User } from 'lucide-react'; +import { useResponsiveStyle } from '../lib/responsive'; interface NavigationProps { currentPage: string; onNavigate: (page: string) => void; } +// 导航项配置 +const navItems = [ + { id: 'home', label: '首页', icon: Home }, + { id: 'devices', label: '设备', icon: Camera }, + { id: 'records', label: '记录', icon: PlusCircle, isFab: true }, + { id: 'health', label: '健康', icon: Heart }, + { id: 'profile', label: '我的', icon: User }, +]; + +// 单个导航项组件 const NavItem = memo(({ label, icon: Icon, isActive, - onClick + onClick, + isFab = false, + colors }: { label: string; icon: React.ElementType; isActive: boolean; onClick: () => void; + isFab?: boolean; + colors: { + activeBg: string; + activeText: string; + inactiveText: string; + fabBg: string; + fabShadow: string; + }; }) => { const handleClick = useCallback(() => { onClick(); }, [onClick]); + // FAB按钮样式 - 中央凸起按钮 + if (isFab) { + return ( + + ); + } + + // 普通导航项样式 return ( ); }); NavItem.displayName = 'NavItem'; -const navItems = [ - { id: 'home', label: '首页', icon: Home }, - { id: 'advanced-health', label: '健康', icon: Shield }, - { id: 'bond-emotion', label: '情感', icon: Sparkles }, - { id: 'camera-monitor', label: '监控', icon: Camera }, - { id: 'profile', label: '我的', icon: User }, -]; - +// 主导航组件 export const Navigation: React.FC = memo(({ currentPage, onNavigate }) => { - const [isVisible, setIsVisible] = useState(true); - const lastScrollY = useRef(0); - const navRef = useRef(null); - - const debouncedNavigate = useMemo( - () => debounce((page: string) => { - requestAnimationFrame(() => { - onNavigate(page); - }); - }, 50), - [onNavigate] - ); - - const handleScroll = useMemo( - () => throttle(() => { - const currentScrollY = window.scrollY; - const diff = currentScrollY - lastScrollY.current; - - if (diff > 50 && currentScrollY > 100) { - setIsVisible(false); - } else if (diff < -10 || currentScrollY < 100) { - setIsVisible(true); - } - - lastScrollY.current = currentScrollY; - }, 100), - [] - ); - - useEffect(() => { - window.addEventListener('scroll', handleScroll, { passive: true }); - return () => { - window.removeEventListener('scroll', handleScroll); - }; - }, [handleScroll]); - - useVisibilityChange( - () => setIsVisible(true), - () => setIsVisible(false) - ); + const responsiveStyle = useResponsiveStyle(); + + // 颜色配置 - 精细调整的颜色搭配 + const colors = useMemo(() => ({ + // 活动状态颜色 - 温暖的橙色系 + activeBg: 'bg-gradient-to-r from-orange-500 to-orange-400', + activeText: 'text-orange-500', + + // 非活动状态颜色 - 柔和的灰色系 + inactiveText: 'text-gray-400', + + // FAB按钮颜色 - 醒目的橙色渐变 + fabBg: 'bg-gradient-to-br from-orange-500 via-orange-400 to-amber-500', + fabShadow: 'shadow-lg shadow-orange-500/40', + + // 导航栏背景 + navBg: 'bg-gradient-to-t from-white via-white to-gray-50/30', + navBorder: 'border-t border-gray-100/80', + }), []); + + // 导航处理 + const handleNavigate = useCallback((page: string) => { + requestAnimationFrame(() => { + onNavigate(page); + }); + }, [onNavigate]); return ( ); }); diff --git a/src/config/androidPerformance.ts b/src/config/androidPerformance.ts new file mode 100644 index 00000000..4aaa8801 --- /dev/null +++ b/src/config/androidPerformance.ts @@ -0,0 +1,399 @@ +/** + * Android Performance Configuration - Android 性能优化配置 + * + * 针对 Android 平台的性能优化设置 + * 确保 Android 应用运行流畅,内存占用合理 + */ + +import { Capacitor } from '@capacitor/core'; + +/** + * 检查是否为 Android 平台 + */ +const isAndroid = (): boolean => { + return Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android'; +}; + +/** + * 性能配置接口 + */ +export interface AndroidPerformanceConfig { + // 动画配置 + animations: { + enabled: boolean; + reducedMotion: boolean; + maxFPS: number; + durationMultiplier: number; + }; + + // 图片配置 + images: { + lazyLoadEnabled: boolean; + maxImageSize: number; // KB + compressionQuality: number; // 0-100 + cacheSize: number; // MB + }; + + // 内存配置 + memory: { + maxCacheItems: number; + cleanupInterval: number; // ms + warningThreshold: number; // MB + }; + + // 网络配置 + network: { + requestTimeout: number; // ms + retryAttempts: number; + cacheEnabled: boolean; + prefetchEnabled: boolean; + }; + + // UI 配置 + ui: { + virtualListThreshold: number; + debounceDelay: number; // ms + throttleDelay: number; // ms + hardwareAcceleration: boolean; + }; + + // 存储配置 + storage: { + maxLocalStorageSize: number; // KB + useNativeStorage: boolean; + encryptionEnabled: boolean; + }; +} + +/** + * 默认 Android 性能配置 + */ +const defaultAndroidConfig: AndroidPerformanceConfig = { + animations: { + enabled: true, + reducedMotion: false, + maxFPS: 60, + durationMultiplier: 1.0, + }, + + images: { + lazyLoadEnabled: true, + maxImageSize: 1024, // 1MB + compressionQuality: 80, + cacheSize: 50, // 50MB + }, + + memory: { + maxCacheItems: 100, + cleanupInterval: 30000, // 30秒 + warningThreshold: 150, // 150MB + }, + + network: { + requestTimeout: 15000, // 15秒 + retryAttempts: 3, + cacheEnabled: true, + prefetchEnabled: false, + }, + + ui: { + virtualListThreshold: 50, + debounceDelay: 300, + throttleDelay: 100, + hardwareAcceleration: true, + }, + + storage: { + maxLocalStorageSize: 5120, // 5MB + useNativeStorage: true, + encryptionEnabled: false, + }, +}; + +/** + * 低端设备性能配置 + */ +const lowEndDeviceConfig: AndroidPerformanceConfig = { + animations: { + enabled: false, + reducedMotion: true, + maxFPS: 30, + durationMultiplier: 0.5, + }, + + images: { + lazyLoadEnabled: true, + maxImageSize: 512, // 512KB + compressionQuality: 60, + cacheSize: 20, // 20MB + }, + + memory: { + maxCacheItems: 50, + cleanupInterval: 15000, // 15秒 + warningThreshold: 100, // 100MB + }, + + network: { + requestTimeout: 20000, // 20秒 + retryAttempts: 2, + cacheEnabled: true, + prefetchEnabled: false, + }, + + ui: { + virtualListThreshold: 30, + debounceDelay: 500, + throttleDelay: 200, + hardwareAcceleration: false, + }, + + storage: { + maxLocalStorageSize: 2048, // 2MB + useNativeStorage: true, + encryptionEnabled: false, + }, +}; + +/** + * 高端设备性能配置 + */ +const highEndDeviceConfig: AndroidPerformanceConfig = { + animations: { + enabled: true, + reducedMotion: false, + maxFPS: 60, + durationMultiplier: 1.0, + }, + + images: { + lazyLoadEnabled: true, + maxImageSize: 2048, // 2MB + compressionQuality: 90, + cacheSize: 100, // 100MB + }, + + memory: { + maxCacheItems: 200, + cleanupInterval: 60000, // 60秒 + warningThreshold: 200, // 200MB + }, + + network: { + requestTimeout: 10000, // 10秒 + retryAttempts: 3, + cacheEnabled: true, + prefetchEnabled: true, + }, + + ui: { + virtualListThreshold: 100, + debounceDelay: 200, + throttleDelay: 50, + hardwareAcceleration: true, + }, + + storage: { + maxLocalStorageSize: 10240, // 10MB + useNativeStorage: true, + encryptionEnabled: true, + }, +}; + +/** + * 设备性能等级 + */ +export type DevicePerformanceLevel = 'low' | 'medium' | 'high'; + +/** + * 检测设备性能等级 + */ +const detectPerformanceLevel = (): DevicePerformanceLevel => { + // 检测设备内存 + const deviceMemory = navigator.deviceMemory || 4; // GB + + // 检测硬件并发数 + const hardwareConcurrency = navigator.hardwareConcurrency || 4; + + // 检测连接速度 + const connection = (navigator as any).connection; + const effectiveType = connection?.effectiveType || '4g'; + + console.log('[AndroidPerformance] Device info:', { + memory: deviceMemory, + cores: hardwareConcurrency, + connection: effectiveType, + }); + + // 低端设备判断 + if (deviceMemory < 2 || hardwareConcurrency < 4 || effectiveType === '2g') { + return 'low'; + } + + // 高端设备判断 + if (deviceMemory >= 6 && hardwareConcurrency >= 8 && effectiveType === '4g') { + return 'high'; + } + + // 默认中等设备 + return 'medium'; +}; + +/** + * 获取性能配置 + */ +export const getAndroidPerformanceConfig = (): AndroidPerformanceConfig => { + // 非 Android 平台使用默认配置 + if (!isAndroid()) { + return defaultAndroidConfig; + } + + // 检测设备性能等级 + const level = detectPerformanceLevel(); + console.log('[AndroidPerformance] Detected performance level:', level); + + // 根据性能等级返回配置 + switch (level) { + case 'low': + return lowEndDeviceConfig; + case 'high': + return highEndDeviceConfig; + default: + return defaultAndroidConfig; + } +}; + +/** + * 应用性能配置到 DOM + */ +export const applyAndroidPerformanceConfig = (config: AndroidPerformanceConfig): void => { + if (!isAndroid()) return; + + const root = document.documentElement; + + // 应用动画配置 + if (!config.animations.enabled || config.animations.reducedMotion) { + root.classList.add('reduce-motion'); + } + + // 应用硬件加速配置 + if (config.ui.hardwareAcceleration) { + root.classList.add('gpu-accelerated'); + } else { + root.classList.add('no-gpu-acceleration'); + } + + // 设置 CSS 变量 + root.style.setProperty('--animation-duration-multiplier', config.animations.durationMultiplier.toString()); + root.style.setProperty('--max-fps', config.animations.maxFPS.toString()); + + console.log('[AndroidPerformance] Applied performance config to DOM'); +}; + +/** + * 性能监控 + */ +export const startPerformanceMonitoring = (config: AndroidPerformanceConfig): void => { + if (!isAndroid()) return; + + // 内存监控 + const memoryCheckInterval = setInterval(() => { + if (performance.memory) { + const usedJSHeapSize = performance.memory.usedJSHeapSize / (1024 * 1024); + console.log('[AndroidPerformance] Memory usage:', usedJSHeapSize.toFixed(2), 'MB'); + + if (usedJSHeapSize > config.memory.warningThreshold) { + console.warn('[AndroidPerformance] Memory usage exceeds threshold!'); + // 触发清理 + triggerMemoryCleanup(); + } + } + }, config.memory.cleanupInterval); + + // FPS 监控 + let lastTime = performance.now(); + let frameCount = 0; + + const fpsMonitor = () => { + frameCount++; + const currentTime = performance.now(); + + if (currentTime - lastTime >= 1000) { + const fps = frameCount; + console.log('[AndroidPerformance] FPS:', fps); + + if (fps < config.animations.maxFPS * 0.5) { + console.warn('[AndroidPerformance] FPS is too low:', fps); + } + + frameCount = 0; + lastTime = currentTime; + } + + requestAnimationFrame(fpsMonitor); + }; + + requestAnimationFrame(fpsMonitor); + + console.log('[AndroidPerformance] Performance monitoring started'); +}; + +/** + * 触发内存清理 + */ +const triggerMemoryCleanup = (): void => { + // 清理图片缓存 + const images = document.querySelectorAll('img[data-loaded]'); + images.forEach(img => { + if (img.getAttribute('data-loaded') === 'cached') { + img.removeAttribute('src'); + img.removeAttribute('data-loaded'); + } + }); + + // 清理隐藏元素 + const hiddenElements = document.querySelectorAll('[style*="display: none"]'); + hiddenElements.forEach(el => { + if (el.innerHTML.length > 1000) { + el.innerHTML = ''; + } + }); + + console.log('[AndroidPerformance] Memory cleanup triggered'); +}; + +/** + * 初始化 Android 性能优化 + */ +export const initializeAndroidPerformance = (): void => { + if (!isAndroid()) { + console.log('[AndroidPerformance] Not Android platform, skipping performance initialization'); + return; + } + + console.log('[AndroidPerformance] Initializing Android performance optimization...'); + + const config = getAndroidPerformanceConfig(); + applyAndroidPerformanceConfig(config); + startPerformanceMonitoring(config); + + console.log('[AndroidPerformance] Android performance optimization initialized'); +}; + +/** + * Android 性能优化 Hook + */ +export function useAndroidPerformance() { + const config = getAndroidPerformanceConfig(); + const isAndroidDevice = isAndroid(); + const performanceLevel = detectPerformanceLevel(); + + return { + config, + isAndroid: isAndroidDevice, + performanceLevel, + applyConfig: applyAndroidPerformanceConfig, + startMonitoring: startPerformanceMonitoring, + initialize: initializeAndroidPerformance, + }; +} \ No newline at end of file diff --git a/src/lib/animations.ts b/src/lib/animations.ts new file mode 100644 index 00000000..0818f74e --- /dev/null +++ b/src/lib/animations.ts @@ -0,0 +1,549 @@ +/** + * Apple Style Animation Library - Apple风格动画库 + * + * 提供Apple级别的流畅动画效果 + * 包括:页面过渡、手势动画、微交互等 + */ + +import { Capacitor } from '@capacitor/core'; + +// 动画配置 +export const AnimationConfig = { + // 动画时长 + duration: { + instant: 0, + fast: 150, + normal: 250, + slow: 350, + slower: 500, + slowest: 700, + }, + + // 动画曲线 + easing: { + linear: 'linear', + easeIn: [0.42, 0, 1, 1], + easeOut: [0, 0, 0.58, 1], + easeInOut: [0.42, 0, 0.58, 1], + // Apple 特殊曲线 + spring: [0.175, 0.885, 0.32, 1.275], + bounce: [0.68, -0.55, 0.265, 1.55], + smooth: [0.25, 0.1, 0.25, 1], + ios: [0.23, 1, 0.32, 1], + iosModal: [0.4, 0, 0.2, 1], + iosSheet: [0.32, 0.72, 0, 1], + }, + + // 原生平台优化 + native: { + useHardwareAcceleration: true, + useWillChange: true, + }, +}; + +// 检测是否为原生平台 +const isNative = () => Capacitor.isNativePlatform(); + +// 将贝塞尔曲线转换为CSS格式 +const bezierToCSS = (bezier: number[]) => + `cubic-bezier(${bezier.join(', ')})`; + +/** + * 页面过渡动画 + */ +export const PageTransitions = { + // iOS 风格页面推入 + push: { + enter: { + opacity: [0, 1], + transform: ['translateX(100%)', 'translateX(0)'], + duration: AnimationConfig.duration.slow, + easing: AnimationConfig.easing.ios, + }, + exit: { + opacity: [1, 0], + transform: ['translateX(0)', 'translateX(-30%)'], + duration: AnimationConfig.duration.slow, + easing: AnimationConfig.easing.ios, + }, + }, + + // iOS 风格页面弹出 + pop: { + enter: { + opacity: [0, 1], + transform: ['translateX(-30%)', 'translateX(0)'], + duration: AnimationConfig.duration.slow, + easing: AnimationConfig.easing.ios, + }, + exit: { + opacity: [1, 0], + transform: ['translateX(0)', 'translateX(100%)'], + duration: AnimationConfig.duration.slow, + easing: AnimationConfig.easing.ios, + }, + }, + + // 模态框弹出 + modal: { + enter: { + opacity: [0, 1], + transform: ['translateY(100%)', 'translateY(0)'], + duration: AnimationConfig.duration.slow, + easing: AnimationConfig.easing.iosModal, + }, + exit: { + opacity: [1, 0], + transform: ['translateY(0)', 'translateY(100%)'], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.iosModal, + }, + }, + + // 底部弹出面板 + sheet: { + enter: { + opacity: [0, 1], + transform: ['translateY(100%)', 'translateY(0)'], + duration: AnimationConfig.duration.slow, + easing: AnimationConfig.easing.iosSheet, + }, + exit: { + opacity: [1, 0], + transform: ['translateY(0)', 'translateY(100%)'], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.iosSheet, + }, + }, + + // 淡入淡出 + fade: { + enter: { + opacity: [0, 1], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.easeOut, + }, + exit: { + opacity: [1, 0], + duration: AnimationConfig.duration.fast, + easing: AnimationConfig.easing.easeIn, + }, + }, +}; + +/** + * 微交互动画 + */ +export const MicroInteractions = { + // 按钮点击 + buttonPress: { + scale: [1, 0.97], + duration: AnimationConfig.duration.fast, + easing: AnimationConfig.easing.ios, + }, + + // 按钮释放 + buttonRelease: { + scale: [0.97, 1], + duration: AnimationConfig.duration.fast, + easing: AnimationConfig.easing.spring, + }, + + // 卡片悬停 + cardHover: { + transform: ['translateY(0)', 'translateY(-2px)'], + boxShadow: [ + '0 2px 8px rgba(0,0,0,0.04)', + '0 8px 16px rgba(0,0,0,0.08)', + ], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.ios, + }, + + // 列表项点击 + listItemPress: { + opacity: [1, 0.7], + duration: AnimationConfig.duration.fast, + easing: AnimationConfig.easing.ios, + }, + + // 开关切换 + toggleSwitch: { + transform: ['translateX(0)', 'translateX(20px)'], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.spring, + }, + + // 图标旋转 + iconRotate: { + transform: ['rotate(0deg)', 'rotate(180deg)'], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.ios, + }, + + // 数字变化 + numberChange: { + opacity: [0, 1], + transform: ['translateY(-10px)', 'translateY(0)'], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.spring, + }, +}; + +/** + * 手势动画 + */ +export const GestureAnimations = { + // 拖拽跟随 + dragFollow: { + transform: 'translateX(x) translateY(y)', + duration: AnimationConfig.duration.instant, + easing: AnimationConfig.easing.linear, + }, + + // 拖拽释放回弹 + dragRelease: { + transform: ['translateX(x) translateY(y)', 'translateX(0) translateY(0)'], + duration: AnimationConfig.duration.slow, + easing: AnimationConfig.easing.spring, + }, + + // 滑动删除 + swipeDelete: { + opacity: [1, 0], + transform: ['translateX(0)', 'translateX(-100%)'], + height: ['auto', '0'], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.ios, + }, + + // 滑动取消 + swipeCancel: { + transform: ['translateX(x)', 'translateX(0)'], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.spring, + }, +}; + +/** + * 加载动画 + */ +export const LoadingAnimations = { + // 骨架屏 + skeleton: { + backgroundPosition: ['-200% 0', '200% 0'], + duration: AnimationConfig.duration.slower, + easing: AnimationConfig.easing.linear, + iterations: Infinity, + }, + + // 进度条 + progress: { + width: ['0%', '100%'], + duration: AnimationConfig.duration.slowest, + easing: AnimationConfig.easing.easeInOut, + }, + + // 旋转加载 + spinner: { + transform: ['rotate(0deg)', 'rotate(360deg)'], + duration: 1000, + easing: AnimationConfig.easing.linear, + iterations: Infinity, + }, + + // 脉冲 + pulse: { + opacity: [1, 0.5, 1], + scale: [1, 1.05, 1], + duration: AnimationConfig.duration.slower, + easing: AnimationConfig.easing.easeInOut, + iterations: Infinity, + }, +}; + +/** + * 反馈动画 + */ +export const FeedbackAnimations = { + // 成功反馈 + success: { + opacity: [0, 1], + scale: [0.5, 1], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.spring, + }, + + // 错误抖动 + errorShake: { + transform: [ + 'translateX(0)', + 'translateX(-10px)', + 'translateX(10px)', + 'translateX(-10px)', + 'translateX(10px)', + 'translateX(0)', + ], + duration: AnimationConfig.duration.slow, + easing: AnimationConfig.easing.easeInOut, + }, + + // 警告闪烁 + warningFlash: { + opacity: [1, 0.3, 1], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.easeInOut, + }, + + // 点赞动画 + like: { + scale: [1, 1.3, 1], + duration: AnimationConfig.duration.normal, + easing: AnimationConfig.easing.bounce, + }, +}; + +/** + * 动画执行器 + */ +export class AnimationExecutor { + private element: HTMLElement; + private useNative: boolean; + + constructor(element: HTMLElement) { + this.element = element; + this.useNative = isNative(); + } + + // 执行动画 + animate(config: { + opacity?: [number, number]; + transform?: [string, string]; + scale?: [number, number]; + boxShadow?: [string, string]; + width?: [string, string]; + height?: [string, string]; + backgroundPosition?: [string, string]; + duration: number; + easing: number[] | string; + iterations?: number; + fill?: 'none' | 'forwards' | 'backwards' | 'both'; + delay?: number; + }): Promise { + return new Promise((resolve) => { + const keyframes: Keyframe[] = []; + + // 构建起始帧 + const startFrame: Keyframe = {}; + const endFrame: Keyframe = { offset: 1 }; + + if (config.opacity) { + startFrame.opacity = config.opacity[0]; + endFrame.opacity = config.opacity[1]; + } + + if (config.transform) { + startFrame.transform = config.transform[0]; + endFrame.transform = config.transform[1]; + } + + if (config.scale) { + startFrame.transform = `scale(${config.scale[0]})`; + endFrame.transform = `scale(${config.scale[1]})`; + } + + if (config.boxShadow) { + startFrame.boxShadow = config.boxShadow[0]; + endFrame.boxShadow = config.boxShadow[1]; + } + + if (config.width) { + startFrame.width = config.width[0]; + endFrame.width = config.width[1]; + } + + if (config.height) { + startFrame.height = config.height[0]; + endFrame.height = config.height[1]; + } + + if (config.backgroundPosition) { + startFrame.backgroundPosition = config.backgroundPosition[0]; + endFrame.backgroundPosition = config.backgroundPosition[1]; + } + + keyframes.push(startFrame, endFrame); + + // 动画选项 + const options: KeyframeAnimationOptions = { + duration: config.duration, + easing: typeof config.easing === 'string' + ? config.easing + : bezierToCSS(config.easing), + fill: config.fill || 'forwards', + iterations: config.iterations || 1, + delay: config.delay || 0, + }; + + // 原生平台优化 + if (this.useNative && AnimationConfig.native.useHardwareAcceleration) { + this.element.style.willChange = 'transform, opacity'; + } + + // 执行动画 + const animation = this.element.animate(keyframes, options); + + animation.onfinish = () => { + if (this.useNative) { + this.element.style.willChange = 'auto'; + } + resolve(); + }; + }); + } + + // 快捷方法 + fadeIn(duration = AnimationConfig.duration.normal): Promise { + return this.animate({ + opacity: [0, 1], + duration, + easing: AnimationConfig.easing.easeOut, + }); + } + + fadeOut(duration = AnimationConfig.duration.fast): Promise { + return this.animate({ + opacity: [1, 0], + duration, + easing: AnimationConfig.easing.easeIn, + }); + } + + slideUp(duration = AnimationConfig.duration.slow): Promise { + return this.animate({ + opacity: [0, 1], + transform: ['translateY(100%)', 'translateY(0)'], + duration, + easing: AnimationConfig.easing.iosModal, + }); + } + + slideDown(duration = AnimationConfig.duration.slow): Promise { + return this.animate({ + opacity: [0, 1], + transform: ['translateY(-100%)', 'translateY(0)'], + duration, + easing: AnimationConfig.easing.iosModal, + }); + } + + scaleIn(duration = AnimationConfig.duration.normal): Promise { + return this.animate({ + opacity: [0, 1], + scale: [0.9, 1], + duration, + easing: AnimationConfig.easing.spring, + }); + } + + scaleOut(duration = AnimationConfig.duration.fast): Promise { + return this.animate({ + opacity: [1, 0], + scale: [1, 0.9], + duration, + easing: AnimationConfig.easing.easeIn, + }); + } + + bounce(duration = AnimationConfig.duration.slow): Promise { + return this.animate({ + scale: [1, 1.1, 1], + duration, + easing: AnimationConfig.easing.bounce, + }); + } + + shake(duration = AnimationConfig.duration.slow): Promise { + return this.animate({ + transform: [ + 'translateX(0)', + 'translateX(-10px)', + 'translateX(10px)', + 'translateX(-10px)', + 'translateX(10px)', + 'translateX(0)', + ], + duration, + easing: AnimationConfig.easing.easeInOut, + }); + } + + press(): Promise { + return this.animate({ + scale: [1, 0.97], + duration: AnimationConfig.duration.fast, + easing: AnimationConfig.easing.ios, + }); + } + + release(): Promise { + return this.animate({ + scale: [0.97, 1], + duration: AnimationConfig.duration.fast, + easing: AnimationConfig.easing.spring, + }); + } +} + +/** + * 创建动画执行器 + */ +export const createAnimation = (element: HTMLElement) => + new AnimationExecutor(element); + +/** + * 批量动画 + */ +export const animateAll = async ( + elements: HTMLElement[], + animationFn: (executor: AnimationExecutor) => Promise +): Promise => { + const promises = elements.map(el => animationFn(new AnimationExecutor(el))); + await Promise.all(promises); +}; + +/** + * 顺序动画 + */ +export const animateSequence = async ( + elements: HTMLElement[], + animationFn: (executor: AnimationExecutor) => Promise, + delay = AnimationConfig.duration.fast +): Promise => { + for (let i = 0; i < elements.length; i++) { + await animationFn(new AnimationExecutor(elements[i])); + if (i < elements.length - 1) { + await new Promise(resolve => setTimeout(resolve, delay)); + } + } +}; + +/** + * React Hook - 使用动画 + */ +export const useAnimation = () => { + const createExecutor = (element: HTMLElement | null) => { + if (!element) return null; + return new AnimationExecutor(element); + }; + + return { + createExecutor, + config: AnimationConfig, + transitions: PageTransitions, + micro: MicroInteractions, + gestures: GestureAnimations, + loading: LoadingAnimations, + feedback: FeedbackAnimations, + }; +}; \ No newline at end of file diff --git a/src/lib/responsive.tsx b/src/lib/responsive.tsx new file mode 100644 index 00000000..3ddfa142 --- /dev/null +++ b/src/lib/responsive.tsx @@ -0,0 +1,347 @@ +/** + * Responsive Design System - 响应式设计系统 + * + * 多端分辨率适配,确保无变形 + * 支持:iPhone SE / iPhone 标准 / iPhone Plus / iPad / iPad Pro + */ + +import { useState, useEffect, useCallback } from 'react'; +import { Capacitor } from '@capacitor/core'; + +// 设备类型 +export type DeviceType = 'phone' | 'tablet' | 'desktop'; + +// 屏幕尺寸 +export type ScreenSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; + +// 屏幕方向 +export type Orientation = 'portrait' | 'landscape'; + +// 设备信息 +export interface DeviceInfo { + type: DeviceType; + size: ScreenSize; + orientation: Orientation; + width: number; + height: number; + pixelRatio: number; + isNative: boolean; + platform: 'web' | 'android' | 'ios'; + safeArea: { + top: number; + bottom: number; + left: number; + right: number; + }; +} + +// 断点配置 +export const breakpoints = { + xs: 320, // iPhone SE + sm: 375, // iPhone 标准 + md: 414, // iPhone Plus + lg: 768, // iPad + xl: 1024, // iPad Pro 11 + '2xl': 1280, // iPad Pro 12.9 +}; + +// 获取屏幕尺寸 +const getScreenSize = (width: number): ScreenSize => { + if (width < breakpoints.xs) return 'xs'; + if (width < breakpoints.sm) return 'xs'; + if (width < breakpoints.md) return 'sm'; + if (width < breakpoints.lg) return 'md'; + if (width < breakpoints.xl) return 'lg'; + if (width < breakpoints['2xl']) return 'xl'; + return '2xl'; +}; + +// 获取设备类型 +const getDeviceType = (width: number): DeviceType => { + if (width < breakpoints.lg) return 'phone'; + if (width < breakpoints['2xl']) return 'tablet'; + return 'desktop'; +}; + +// 获取屏幕方向 +const getOrientation = (width: number, height: number): Orientation => + width > height ? 'landscape' : 'portrait'; + +// 获取安全区域 +const getSafeArea = (): { top: number; bottom: number; left: number; right: number } => { + return { + top: 0, + bottom: 0, + left: 0, + right: 0, + }; +}; + +/** + * 获取设备信息 + */ +export const getDeviceInfo = (): DeviceInfo => { + const width = window.innerWidth; + const height = window.innerHeight; + const pixelRatio = window.devicePixelRatio || 1; + + return { + type: getDeviceType(width), + size: getScreenSize(width), + orientation: getOrientation(width, height), + width, + height, + pixelRatio, + isNative: Capacitor.isNativePlatform(), + platform: Capacitor.isNativePlatform() + ? Capacitor.getPlatform() as 'android' | 'ios' + : 'web', + safeArea: getSafeArea(), + }; +}; + +/** + * 响应式 Hook + */ +export const useResponsive = () => { + const [deviceInfo, setDeviceInfo] = useState(getDeviceInfo()); + + const updateDeviceInfo = useCallback(() => { + setDeviceInfo(getDeviceInfo()); + }, []); + + useEffect(() => { + window.addEventListener('resize', updateDeviceInfo); + window.addEventListener('orientationchange', updateDeviceInfo); + + return () => { + window.removeEventListener('resize', updateDeviceInfo); + window.removeEventListener('orientationchange', updateDeviceInfo); + }; + }, [updateDeviceInfo]); + + return { + ...deviceInfo, + isPhone: deviceInfo.type === 'phone', + isTablet: deviceInfo.type === 'tablet', + isDesktop: deviceInfo.type === 'desktop', + isPortrait: deviceInfo.orientation === 'portrait', + isLandscape: deviceInfo.orientation === 'landscape', + isSmall: deviceInfo.size === 'xs' || deviceInfo.size === 'sm', + isMedium: deviceInfo.size === 'md', + isLarge: deviceInfo.size === 'lg' || deviceInfo.size === 'xl' || deviceInfo.size === '2xl', + }; +}; + +/** + * 响应式值 Hook + */ +export const useResponsiveValue = (values: Partial>, defaultValue: T): T => { + const { size } = useResponsive(); + const sizeOrder: ScreenSize[] = [size, 'md', 'sm', 'xs']; + + for (const s of sizeOrder) { + if (values[s] !== undefined) { + return values[s]!; + } + } + + return defaultValue; +}; + +// 导出响应式样式Hook +export { useResponsiveStyle } from './responsiveStyle'; + +/** + * 响应式图片尺寸 + */ +export const getResponsiveImageSize = ( + originalWidth: number, + originalHeight: number, + containerWidth: number, + containerHeight?: number +): { width: number; height: number } => { + // 保持比例,不变形 + const aspectRatio = originalWidth / originalHeight; + + if (containerHeight) { + // 有容器高度限制 + const widthByHeight = containerHeight * aspectRatio; + + if (widthByHeight <= containerWidth) { + return { + width: widthByHeight, + height: containerHeight, + }; + } else { + return { + width: containerWidth, + height: containerWidth / aspectRatio, + }; + } + } else { + // 只有宽度限制 + return { + width: containerWidth, + height: containerWidth / aspectRatio, + }; + } +}; + +/** + * 响应式字体缩放 + */ +export const getResponsiveFontSize = (baseSize: number, pixelRatio: number): number => { + // 根据像素密度调整字体大小,确保清晰度 + if (pixelRatio >= 3) { + // 高分辨率屏幕(如 iPhone Pro) + return baseSize; + } else if (pixelRatio >= 2) { + // 标准视网膜屏幕 + return baseSize; + } else { + // 低分辨率屏幕 + return Math.round(baseSize * 1.1); + } +}; + +/** + * CSS 响应式类名生成 + */ +export const responsiveClasses = { + // 容器 + container: (size: ScreenSize) => { + const maxWidths: Record = { + xs: 'max-w-xs', + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl', + '2xl': 'max-w-2xl', + }; + return maxWidths[size]; + }, + + // 网格列数 + gridCols: (size: ScreenSize) => { + const cols: Record = { + xs: 'grid-cols-2', + sm: 'grid-cols-3', + md: 'grid-cols-4', + lg: 'grid-cols-6', + xl: 'grid-cols-8', + '2xl': 'grid-cols-10', + }; + return cols[size]; + }, + + // 间距 + gap: (size: ScreenSize) => { + const gaps: Record = { + xs: 'gap-2', + sm: 'gap-3', + md: 'gap-4', + lg: 'gap-6', + xl: 'gap-8', + '2xl': 'gap-10', + }; + return gaps[size]; + }, + + // 内边距 + padding: (size: ScreenSize) => { + const paddings: Record = { + xs: 'p-3', + sm: 'p-4', + md: 'p-5', + lg: 'p-6', + xl: 'p-8', + '2xl': 'p-10', + }; + return paddings[size]; + }, + + // 文字大小 + text: (size: ScreenSize) => { + const texts: Record = { + xs: 'text-sm', + sm: 'text-base', + md: 'text-lg', + lg: 'text-xl', + xl: 'text-2xl', + '2xl': 'text-3xl', + }; + return texts[size]; + }, +}; + +/** + * 响应式媒体查询 CSS + */ +export const responsiveMediaQueries = { + xs: '@media screen and (max-width: 319px)', + sm: '@media screen and (min-width: 320px) and (max-width: 374px)', + md: '@media screen and (min-width: 375px) and (max-width: 413px)', + lg: '@media screen and (min-width: 414px) and (max-width: 767px)', + xl: '@media screen and (min-width: 768px) and (max-width: 1023px)', + '2xl': '@media screen and (min-width: 1024px)', +}; + +/** + * 响应式图片 URL 生成 + */ +export const getResponsiveImageUrl = ( + baseUrl: string, + width: number, + options?: { + height?: number; + quality?: number; + format?: 'jpeg' | 'png' | 'webp'; + } +): string => { + // 如果是 Unsplash 图片,使用其 API 调整尺寸 + if (baseUrl.includes('unsplash.com')) { + const params = new URLSearchParams({ + w: width.toString(), + fit: 'crop', + crop: 'faces', + auto: 'format', + q: (options?.quality || 80).toString(), + }); + + if (options?.height) { + params.set('h', options.height.toString()); + } + + return `${baseUrl}?${params.toString()}`; + } + + // 其他图片源,返回原 URL + return baseUrl; +}; + +/** + * 响应式组件包装器 + */ +export const ResponsiveWrapper: React.FC<{ + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; +}> = ({ children, className = '', style = {} }) => { + const { size, safeAreaPadding } = useResponsiveStyle(); + + return ( +
+ {children} +
+ ); +}; \ No newline at end of file diff --git a/src/lib/responsiveStyle.ts b/src/lib/responsiveStyle.ts new file mode 100644 index 00000000..f3bf2a8c --- /dev/null +++ b/src/lib/responsiveStyle.ts @@ -0,0 +1,137 @@ +/** + * Responsive Style Hook - 响应式样式Hook + * + * 提供响应式样式值,确保多端适配 + */ + +import { useMemo } from 'react'; +import { useResponsive } from './responsive'; + +/** + * 安全区域Padding接口 + */ +export interface SafeAreaPadding { + paddingTop: number; + paddingBottom: number; + paddingLeft: number; + paddingRight: number; +} + +/** + * 响应式样式返回值接口 + */ +export interface ResponsiveStyleValues { + fontSize: number; + spacing: number; + cardRadius: number; + buttonHeight: number; + avatarSize: number; + iconSize: number; + contentMaxWidth: number; + safeAreaPadding: SafeAreaPadding; + screenWidth: number; + screenHeight: number; + screenSize: string; + isPortrait: boolean; + isLandscape: boolean; + getResponsiveValue: (values: Partial>, defaultValue: T) => T; +} + +/** + * 响应式样式Hook + */ +export const useResponsiveStyle = (): ResponsiveStyleValues => { + const { size, isPortrait, safeArea, width, height } = useResponsive(); + + return useMemo(() => { + // 根据屏幕尺寸计算样式值 + const getResponsiveValue = ( + values: Partial>, + defaultValue: T + ): T => { + const sizeOrder = ['xs', 'sm', 'md', 'lg', 'xl', '2xl']; + const currentIndex = sizeOrder.indexOf(size); + + // 从当前尺寸开始向下查找 + for (let i = currentIndex; i >= 0; i--) { + if (values[sizeOrder[i]] !== undefined) { + return values[sizeOrder[i]]!; + } + } + + // 向上查找 + for (let i = currentIndex + 1; i < sizeOrder.length; i++) { + if (values[sizeOrder[i]] !== undefined) { + return values[sizeOrder[i]]!; + } + } + + return defaultValue; + }; + + // 安全区域padding - 确保返回数值类型 + const safeAreaPadding: SafeAreaPadding = { + paddingTop: typeof safeArea.top === 'number' ? safeArea.top : 0, + paddingBottom: typeof safeArea.bottom === 'number' ? safeArea.bottom : 0, + paddingLeft: typeof safeArea.left === 'number' ? safeArea.left : 0, + paddingRight: typeof safeArea.right === 'number' ? safeArea.right : 0, + }; + + return { + // 字体大小 + fontSize: getResponsiveValue( + { xs: 14, sm: 15, md: 16, lg: 17, xl: 18 }, + 15 + ), + + // 间距 + spacing: getResponsiveValue( + { xs: 12, sm: 16, md: 18, lg: 24, xl: 28 }, + 16 + ), + + // 卡片圆角 + cardRadius: getResponsiveValue( + { xs: 12, sm: 14, md: 16, lg: 18, xl: 20 }, + 14 + ), + + // 按钮高度 + buttonHeight: getResponsiveValue( + { xs: 40, sm: 44, md: 48, lg: 52, xl: 56 }, + 44 + ), + + // 头像大小 + avatarSize: getResponsiveValue( + { xs: 48, sm: 56, md: 64, lg: 80, xl: 96 }, + 56 + ), + + // 图标大小 + iconSize: getResponsiveValue( + { xs: 20, sm: 24, md: 28, lg: 32, xl: 36 }, + 24 + ), + + // 内容最大宽度 + contentMaxWidth: getResponsiveValue( + { xs: 320, sm: 375, md: 414, lg: 768, xl: 1024 }, + 375 + ), + + // 安全区域padding + safeAreaPadding, + + // 屏幕信息 + screenWidth: width, + screenHeight: height, + screenSize: size, + isPortrait, + isLandscape: !isPortrait, + + // 工具函数 + getResponsiveValue, + }; + }, [size, isPortrait, safeArea, width, height]); +}; \ No newline at end of file diff --git a/src/pages/DeveloperInfoPage.tsx b/src/pages/DeveloperInfoPage.tsx index 7922dd30..394d2eb8 100644 --- a/src/pages/DeveloperInfoPage.tsx +++ b/src/pages/DeveloperInfoPage.tsx @@ -15,7 +15,7 @@ interface DeveloperInfoPageProps { export default function DeveloperInfoPage({ onNavigate }: DeveloperInfoPageProps) { const developerInfo = { - name: '热爱生活的小陈', + name: '带娃的小陈工', title: '全栈开发者 & 宠物爱好者', avatar: '👨‍💻', bio: '一个热爱编程、热爱生活、热爱宠物的开发者。在带娃之余,用代码创造美好,让科技温暖每一个毛孩子。', diff --git a/src/pages/DevicesPage.tsx b/src/pages/DevicesPage.tsx new file mode 100644 index 00000000..723f07cc --- /dev/null +++ b/src/pages/DevicesPage.tsx @@ -0,0 +1,235 @@ +import React, { useState, useEffect } from 'react'; +import { + ChevronLeft, + Plus, + Battery, + Wifi, + Settings, + MoreVertical, + Bell, + Camera, + Activity, + Droplets, + Utensils, + Zap, + Trash2, + Power +} from 'lucide-react'; +import { useDevicesStore, type Device, type DeviceType } from '../store/devicesStore'; + +interface DevicesPageProps { + onNavigate: (page: string) => void; +} + +const DevicesPage: React.FC = ({ onNavigate }) => { + const { + devices, + isLoading, + isPairing, + getStats, + initialize, + startPairing, + cancelPairing, + removeDevice, + updateDeviceStatus, + } = useDevicesStore(); + + const [showAddModal, setShowAddModal] = useState(false); + + useEffect(() => { + initialize(); + }, [initialize]); + + const stats = getStats() || { total: 0, online: 0, offline: 0, warning: 0 }; + + const getDeviceIcon = (type: string) => { + switch (type) { + case 'camera': return Camera; + case 'collar': return Activity; + case 'dispenser': return Droplets; + case 'bowl': return Utensils; + default: return Zap; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'online': return 'bg-green-500'; + case 'warning': return 'bg-yellow-500'; + case 'offline': return 'bg-gray-400'; + default: return 'bg-gray-400'; + } + }; + + const getStatusText = (status: string) => { + switch (status) { + case 'online': return '在线'; + case 'warning': return '注意'; + case 'offline': return '离线'; + default: return '未知'; + } + }; + + const getBatteryColor = (battery: number) => { + if (battery > 60) return 'text-green-500'; + if (battery > 20) return 'text-yellow-500'; + return 'text-red-500'; + }; + + return ( +
+ {/* 顶部导航 */} +
+
+ +

我的设备

+ +
+
+ + {/* 设备统计 */} +
+
+
+
+

已连接设备

+

{stats.total || 0}

+
+
+ +
+
+
+
+
+ {stats.online || 0} 在线 +
+
+
+ {stats.warning || 0} 注意 +
+
+
+
+ + {/* 设备列表 */} +
+

设备列表

+
+ {devices.map((device) => { + const DeviceIcon = getDeviceIcon(device.type); + return ( +
+
+
+
+ +
+
+
+

{device.name}

+ +
+

{getStatusText(device.status)} · {device.lastActive}

+
+
+ +
+ + {/* 设备状态详情 */} +
+
+ + {device.battery}% +
+
+ + {device.signal}% +
+
+ + {/* 快捷操作 */} +
+ {device.type === 'camera' && ( + + )} + + +
+
+ ); + })} +
+ + {/* 添加设备按钮 */} + +
+ + {/* 添加设备模态框 */} + {showAddModal && ( +
+
+
+

添加设备

+ +
+
+ {[ + { icon: Camera, name: '摄像头', desc: '实时监控' }, + { icon: Activity, name: '智能项圈', desc: '活动追踪' }, + { icon: Droplets, name: '饮水机', desc: '智能喂水' }, + { icon: Utensils, name: '喂食器', desc: '定时喂食' }, + ].map((item, index) => ( + + ))} +
+
+
+ )} +
+ ); +}; + +export default DevicesPage; diff --git a/src/pages/DietDataPage.tsx b/src/pages/DietDataPage.tsx new file mode 100644 index 00000000..14b7710d --- /dev/null +++ b/src/pages/DietDataPage.tsx @@ -0,0 +1,799 @@ +import React, { useState, useEffect } from 'react'; +import { + ChevronLeft, + Calendar, + ChevronDown, + Utensils, + Droplets, + Flame, + Clock, + TrendingUp, + TrendingDown, + Minus, + PieChart, + BarChart3, + Plus, + Camera, + Trash2, + Edit3, + X, +} from 'lucide-react'; +import { useDietStore, type TimeRange, type MealType, type DietRecord } from '../store/dietStore'; +import { usePetStore } from '../store/petStore'; +import { Card, Badge, EmptyState, Button } from '../components/DesignSystem'; +import { useResponsiveStyle } from '../lib/responsiveStyle'; +import { Camera as CameraPlugin, CameraResultType } from '@capacitor/camera'; + +interface DietDataPageProps { + onNavigate: (page: string) => void; +} + +// 餐食类型配置 +const MEAL_TYPE_CONFIG: Record = { + breakfast: { label: '早餐', icon: Utensils, color: '#F59E0B' }, + lunch: { label: '午餐', icon: Utensils, color: '#10B981' }, + dinner: { label: '晚餐', icon: Utensils, color: '#8B5CF6' }, + snack: { label: '零食', icon: Utensils, color: '#EC4899' }, + treat: { label: '奖励', icon: Utensils, color: '#06B6D4' }, +}; + +export const DietDataPage: React.FC = ({ onNavigate }) => { + const responsive = useResponsiveStyle(); + const { currentPetId } = usePetStore(); + const { + timeRange, + setTimeRange, + selectedDate, + setSelectedDate, + records, + advices, + getStats, + getNutritionIntake, + getRecordsByDate, + getRecordsByRange, + getAdvicesByPet, + addRecord, + updateRecord, + deleteRecord, + initialize, + isLoading, + } = useDietStore(); + + const [showAddModal, setShowAddModal] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + const [showDatePicker, setShowDatePicker] = useState(false); + const [newRecord, setNewRecord] = useState<{ + type: MealType; + food: string; + amount: number; + calories: number; + time: string; + note: string; + }>({ + type: 'breakfast', + food: '', + amount: 100, + calories: 0, + time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), + note: '', + }); + + useEffect(() => { + initialize(); + }, [initialize]); + + // 获取统计数据 + const stats = currentPetId ? getStats(currentPetId, timeRange) : null; + const nutrition = currentPetId ? getNutritionIntake(currentPetId) : null; + const todayRecords = getRecordsByDate(selectedDate); + const rangeRecords = getRecordsByRange(timeRange); + const petAdvices = currentPetId ? getAdvicesByPet(currentPetId) : []; + + // 计算变化趋势 + const calculateTrend = (current: number, previous: number) => { + if (previous === 0) return 0; + return Math.round(((current - previous) / previous) * 100); + }; + + // 今日数据 + const todayData = [ + { + type: 'feeding', + label: '进食次数', + value: stats?.totalMeals || 0, + unit: '次', + change: 0, + changeLabel: '较昨日', + icon: Utensils, + color: '#3B82F6', + bgColor: '#EFF6FF', + }, + { + type: 'drinking', + label: '进食总量', + value: stats?.totalAmount || 0, + unit: 'g', + change: 0, + changeLabel: '较昨日', + icon: Droplets, + color: '#10B981', + bgColor: '#ECFDF5', + }, + { + type: 'calories', + label: '消耗卡路里', + value: stats?.totalCalories || 0, + unit: 'kcal', + change: 0, + changeLabel: '较昨日', + icon: Flame, + color: '#F59E0B', + bgColor: '#FFFBEB', + }, + { + type: 'duration', + label: '进食时长', + value: stats?.totalDuration || 0, + unit: '分钟', + change: 0, + changeLabel: '较昨日', + icon: Clock, + color: '#8B5CF6', + bgColor: '#F5F3FF', + }, + ]; + + // 周数据(从真实记录计算) + const getWeekData = () => { + const weekData: { date: string; feeding: number; drinking: number; calories: number; duration: number }[] = []; + const today = new Date(); + + for (let i = 6; i >= 0; i--) { + const date = new Date(today); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split('T')[0]; + const dayRecords = (records || []).filter(r => r?.date === dateStr); + + const dayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + weekData.push({ + date: dayNames[date.getDay()], + feeding: dayRecords.length || 0, + drinking: dayRecords.reduce((sum, r) => sum + (r?.amount || 0), 0), + calories: dayRecords.reduce((sum, r) => sum + (r?.calories || 0), 0), + duration: dayRecords.length * 1.5 || 0, + }); + } + + return weekData; + }; + + const weekData = getWeekData(); + + // 营养摄入分布 + const nutritionData = nutrition ? [ + { name: '蛋白质', value: nutrition.protein, color: '#3B82F6' }, + { name: '脂肪', value: nutrition.fat, color: '#F59E0B' }, + { name: '碳水化合物', value: nutrition.carbohydrate, color: '#10B981' }, + { name: '纤维', value: nutrition.fiber, color: '#8B5CF6' }, + ] : []; + + // 获取变化趋势图标 + const getTrendIcon = (change: number) => { + if (change > 0) return ; + if (change < 0) return ; + return ; + }; + + // 获取变化颜色 + const getTrendColor = (change: number, type: string) => { + const positiveIncrease = ['feeding', 'calories', 'duration']; + const isPositive = positiveIncrease.includes(type); + + if (change === 0) return 'text-gray-400'; + if (isPositive) { + return change > 0 ? 'text-green-500' : 'text-red-500'; + } else { + return change < 0 ? 'text-green-500' : 'text-red-500'; + } + }; + + // 添加记录 + const handleAddRecord = async () => { + if (!currentPetId || !newRecord.food) return; + + await addRecord({ + petId: currentPetId, + type: newRecord.type, + food: newRecord.food, + amount: newRecord.amount, + calories: newRecord.calories, + time: newRecord.time, + date: selectedDate, + note: newRecord.note, + }); + + setShowAddModal(false); + setNewRecord({ + type: 'breakfast', + food: '', + amount: 100, + calories: 0, + time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), + note: '', + }); + }; + + // 更新记录 + const handleUpdateRecord = async () => { + if (!editingRecord) return; + + await updateRecord(editingRecord.id, { + type: newRecord.type, + food: newRecord.food, + amount: newRecord.amount, + calories: newRecord.calories, + time: newRecord.time, + note: newRecord.note, + }); + + setEditingRecord(null); + setShowAddModal(false); + setNewRecord({ + type: 'breakfast', + food: '', + amount: 100, + calories: 0, + time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), + note: '', + }); + }; + + // 删除记录 + const handleDeleteRecord = async (id: string) => { + await deleteRecord(id); + }; + + // 拍照添加记录 + const handleTakePhoto = async () => { + try { + const photo = await CameraPlugin.getPhoto({ + quality: 90, + allowEditing: true, + resultType: CameraResultType.Base64, + }); + + // 这里可以添加图片分析逻辑,识别食物类型和数量 + console.log('Photo taken:', photo.base64String); + + // 设置图片并打开添加模态框 + setNewRecord(prev => ({ + ...prev, + note: prev.note + ' [有图片]', + })); + setShowAddModal(true); + } catch (error) { + console.error('Camera error:', error); + } + }; + + // 编辑记录 + const handleEditRecord = (record: DietRecord) => { + setEditingRecord(record); + setNewRecord({ + type: record.type, + food: record.food, + amount: record.amount, + calories: record.calories || 0, + time: record.time, + note: record.note || '', + }); + setShowAddModal(true); + }; + + // 格式化日期显示 + const formatDateDisplay = (dateStr: string) => { + const date = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (date.toDateString() === today.toDateString()) { + return '今天'; + } + + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + if (date.toDateString() === yesterday.toDateString()) { + return '昨天'; + } + + return date.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' }); + }; + + return ( +
+ {/* 顶部导航 */} +
+
+ +

饮食数据

+
+ + +
+
+
+ + {/* 日期选择器 */} +
+
+ + + + +
+ + {/* 日期快速选择 */} + {showDatePicker && ( +
+ {Array.from({ length: 7 }).map((_, i) => { + const date = new Date(); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split('T')[0]; + const isSelected = selectedDate === dateStr; + + return ( + + ); + })} +
+ )} +
+ + {/* 加载状态 */} + {isLoading && ( +
+
+
+ )} + + {/* 今日数据概览 */} + {!isLoading && ( +
+

+ {timeRange === 'day' ? '今日概览' : timeRange === 'week' ? '本周概览' : '本月概览'} +

+
+ {todayData.map((item, index) => ( + +
+
+ +
+ {item.label} +
+
+ {item.value} + {item.unit} +
+
+ {getTrendIcon(item.change)} + {Math.abs(item.change)}% + {item.changeLabel} +
+
+
+ 正常 +
+
+ ))} +
+
+ )} + + {/* 今日饮食记录列表 */} + {!isLoading && timeRange === 'day' && ( +
+

今日饮食记录

+ {todayRecords.length === 0 ? ( + setShowAddModal(true)}> + 添加记录 + + } + /> + ) : ( +
+ {todayRecords.map((record) => { + const typeConfig = MEAL_TYPE_CONFIG[record.type]; + return ( + +
+
+
+ +
+
+
+ {record.food} + + {typeConfig.label} + +
+
+ {record.amount}g + {record.calories && · {record.calories} kcal} + + {record.time} +
+ {record.note && ( +

{record.note}

+ )} +
+
+
+ + +
+
+
+ ); + })} +
+ )} +
+ )} + + {/* 周趋势图表 */} + {!isLoading && timeRange === 'week' && ( +
+ +
+

本周趋势

+ +
+
+ {/* 进食次数趋势 */} +
+
+ 进食次数 + + 平均 {(weekData.reduce((sum, d) => sum + d.feeding, 0) / 7).toFixed(1)} 次/天 + +
+
+ {weekData.map((day, index) => ( +
+
+
+
+ {day.date} +
+ ))} +
+
+ + {/* 卡路里趋势 */} +
+
+ 消耗卡路里 + + 平均 {Math.round(weekData.reduce((sum, d) => sum + d.calories, 0) / 7)} kcal/天 + +
+
+ {weekData.map((day, index) => ( +
+
+
+
+ {day.date} +
+ ))} +
+
+
+
+
+ )} + + {/* 营养摄入分布 */} + {!isLoading && nutritionData.length > 0 && ( +
+ +

营养摄入分布

+
+ {/* 饼图示意 */} +
+ + {nutritionData.reduce((acc, item, index) => { + const prevOffset = acc.offset; + const dashArray = `${item.value} ${100 - item.value}`; + acc.elements.push( + + ); + acc.offset += item.value; + return acc; + }, { elements: [] as React.ReactNode[], offset: 0 }).elements} + +
+
+ 100% +

营养均衡

+
+
+
+ + {/* 图例 */} +
+ {nutritionData.map((item, index) => ( +
+
+
+ {item.name} +
+ {item.value}% +
+ ))} +
+
+
+
+ )} + + {/* 饮食建议 */} + {!isLoading && petAdvices.length > 0 && ( +
+
+

饮食建议

+ {petAdvices.map((advice) => ( +

+ {advice.content} +

+ ))} + +
+
+ )} + + {/* 添加/编辑记录模态框 */} + {showAddModal && ( +
+ +
+

+ {editingRecord ? '编辑饮食记录' : '添加饮食记录'} +

+ +
+ +
+ {/* 餐食类型 */} +
+ +
+ {Object.entries(MEAL_TYPE_CONFIG).map(([type, config]) => ( + + ))} +
+
+ + {/* 食物名称 */} +
+ + setNewRecord(prev => ({ ...prev, food: e.target.value }))} + placeholder="例如:狗粮、零食..." + className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-orange-500 focus:outline-none transition-colors" + /> +
+ + {/* 数量 */} +
+ + setNewRecord(prev => ({ ...prev, amount: parseInt(e.target.value) || 0 }))} + className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-orange-500 focus:outline-none transition-colors" + /> +
+ + {/* 卡路里 */} +
+ + setNewRecord(prev => ({ ...prev, calories: parseInt(e.target.value) || 0 }))} + className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-orange-500 focus:outline-none transition-colors" + /> +
+ + {/* 时间 */} +
+ + setNewRecord(prev => ({ ...prev, time: e.target.value }))} + className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-orange-500 focus:outline-none transition-colors" + /> +
+ + {/* 备注 */} +
+ + setNewRecord(prev => ({ ...prev, note: e.target.value }))} + placeholder="可选备注..." + className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-orange-500 focus:outline-none transition-colors" + /> +
+ + {/* 提交按钮 */} +
+ + +
+
+
+
+ )} +
+ ); +}; + +export default DietDataPage; \ No newline at end of file diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 2cddffed..32bf8d34 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,584 +1,649 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { - ChevronRight, - Heart, - Bot, - FileText, - BookOpen, - Calendar, - Activity, - Star, - Clock, - ArrowUpRight, +/** + * HomePage - 首页 + * + * 优化功能入口布局,确保所有功能真实完整 + * Apple风格设计,完美适配多端 + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { + Stethoscope, + FileText, + Utensils, + PawPrint, Camera, - Video, + Activity, + Heart, + Bell, MessageCircle, - RefreshCw, - Sparkles, - Bell + Calendar, + ChevronRight, + Plus, + Battery, + Droplets, + Flame, + Clock, + Zap, + TrendingUp, + Eye, + Shield, + BookOpen, + Music, } from 'lucide-react'; -import { GlassCard, SkeletonCard as _SkeletonCard, SkeletonQuickActions, LiquidHeroCard } from '../components/DesignSystem'; -import '../styles/animations.css'; import { useAppStore } from '../store/appStore'; -import { useBondStore } from '../store/bondStore'; import { usePetStore } from '../store/petStore'; -import { useReminderStore } from '../store/reminderStore'; -import { useHealthRecordStore } from '../store/healthRecordStore'; -import { cameraAdapterService } from '../services/cameraAdapterService'; -import type { CameraDevice } from '../types/camera'; -import { usePullToRefresh } from '../hooks/useGestures'; +import { useBondStore } from '../store/bondStore'; +import { useDevicesStore } from '../store/devicesStore'; +import { useDietStore } from '../store/dietStore'; +import { useRecordsStore } from '../store/recordsStore'; +import { useHealthStore } from '../store/healthStore'; +import { useResponsiveStyle } from '../lib/responsive'; +import { createAnimation, AnimationConfig } from '../lib/animations'; interface HomePageProps { onNavigate: (page: string) => void; } -interface QuickAction { +// 功能分类 +type FeatureCategory = 'health' | 'monitor' | 'care' | 'tools'; + +// 功能项 +interface FeatureItem { + id: string; icon: React.ElementType; label: string; description: string; color: string; - bgGradient: string; + bgColor: string; page: string; + category: FeatureCategory; badge?: string; badgeColor?: string; - isPrimary?: boolean; + isReal?: boolean; // 标记是否为真实功能 } export const HomePage: React.FC = ({ onNavigate }) => { - const { currentPet, currentEmotion, healthScore } = useAppStore(); - const { metrics, badges, totalPoints, streakDays } = useBondStore(); - const unlockedBadges = badges.filter(b => b.isUnlocked).length; + const { currentPet, healthScore } = useAppStore(); const { pets, currentPetId, setCurrentPet } = usePetStore(); - const { getUpcomingReminders } = useReminderStore(); - const { getFilteredRecords } = useHealthRecordStore(); - const [cameras, setCameras] = useState([]); + const { metrics, totalPoints, streakDays } = useBondStore(); + const { devices, getStats, initialize: initDevices } = useDevicesStore(); + const { getStats: getDietStats, initialize: initDiet } = useDietStore(); + const { initialize: initRecords } = useRecordsStore(); + const { getScore, initialize: initHealth } = useHealthStore(); + const responsiveStyle = useResponsiveStyle(); + const [isLoading, setIsLoading] = useState(true); - const [isRefreshing, setIsRefreshing] = useState(false); - const [pullDistance, setPullDistance] = useState(0); - const [isPulling, setIsPulling] = useState(false); - const touchStartY = useRef(0); - const containerRef = useRef(null); - - const loadData = useCallback(async () => { - const devices = await cameraAdapterService.getDevices(); - setCameras(devices); - setIsLoading(false); - }, []); + // 初始化所有数据 useEffect(() => { - loadData(); - }, [loadData]); - - const handleRefresh = useCallback(async () => { - setIsRefreshing(true); - await new Promise(resolve => setTimeout(resolve, 1000)); - await loadData(); - setIsRefreshing(false); - setPullDistance(0); - }, [loadData]); - - const handleTouchStart = useCallback((e: React.TouchEvent) => { - if (containerRef.current?.scrollTop === 0) { - touchStartY.current = e.touches[0].clientY; - setIsPulling(true); - } + const initAll = async () => { + await Promise.all([ + initDevices(), + initDiet(), + initRecords(), + initHealth(), + ]); + setIsLoading(false); + }; + initAll(); }, []); - const handleTouchMove = useCallback((e: React.TouchEvent) => { - if (!isPulling || isRefreshing) return; - - const currentY = e.touches[0].clientY; - const diff = currentY - touchStartY.current; - - if (diff > 0 && containerRef.current?.scrollTop === 0) { - const resistance = 0.5; - const newDistance = Math.min(diff * resistance, 100); - setPullDistance(newDistance); - } - }, [isPulling, isRefreshing]); - - const handleTouchEnd = useCallback(() => { - setIsPulling(false); - if (pullDistance > 60 && !isRefreshing) { - handleRefresh(); - } else { - setPullDistance(0); - } - }, [pullDistance, isRefreshing, handleRefresh]); - - const upcomingReminders = currentPetId ? getUpcomingReminders(currentPetId, 3) : []; - const recentRecords = currentPetId ? getFilteredRecords(currentPetId).slice(0, 3) : []; - const onlineCameras = cameras.filter(c => c.status === 'online'); + // 获取统计数据 - 使用默认值防止 undefined + const deviceStats = getStats() || { total: 0, online: 0, offline: 0, warning: 0 }; + const dietStats = getDietStats('pet-1', 'day') || { calories: 0, foodAmount: 0, waterAmount: 0, meals: 0 }; + const healthScoreData = getScore('pet-1') || { overall: 0, items: [] }; - const quickActions: QuickAction[] = [ - { - icon: Video, - label: '实时监控', - description: '查看毛孩动态', - color: '#3B82F6', - bgGradient: 'from-blue-500 via-blue-600 to-cyan-500', - page: 'camera-monitor', - badge: onlineCameras.length > 0 ? `${onlineCameras.length}台` : undefined, - badgeColor: 'bg-blue-500', - isPrimary: true, - }, - { - icon: Bot, - label: 'AI健康顾问', - description: '智能问诊咨询', - color: '#8B5CF6', - bgGradient: 'from-purple-500 via-violet-500 to-fuchsia-500', - page: 'ai-consultant', - badge: 'AI', - badgeColor: 'bg-gradient-to-r from-purple-500 to-pink-500', - isPrimary: true, + // 功能网格 - 分类展示 + const featureCategories: Record = { + health: { + title: '健康管理', + items: [ + { + id: 'ai-consultant', + icon: Stethoscope, + label: 'AI问诊', + description: '智能诊断', + color: '#3B82F6', + bgColor: '#EFF6FF', + page: 'ai-consultant', + category: 'health', + badge: '在线', + badgeColor: '#34C759', + isReal: true, + }, + { + id: 'health-report', + icon: FileText, + label: '健康报告', + description: '今日生成', + color: '#10B981', + bgColor: '#ECFDF5', + page: 'health-report', + category: 'health', + badge: healthScoreData.overall + '分', + badgeColor: '#10B981', + isReal: true, + }, + { + id: 'health-records', + icon: Heart, + label: '健康记录', + description: '详细追踪', + color: '#EF4444', + bgColor: '#FEE2E2', + page: 'health-records', + category: 'health', + isReal: true, + }, + { + id: 'reminders', + icon: Bell, + label: '健康提醒', + description: '定时提醒', + color: '#8B5CF6', + bgColor: '#F5F3FF', + page: 'reminders', + category: 'health', + isReal: true, + }, + ], }, - { - icon: MessageCircle, - label: '情绪翻译', - description: '读懂宠物心声', - color: '#F97316', - bgGradient: 'from-orange-500 via-red-400 to-rose-500', - page: 'translator', - badge: '95%+', - badgeColor: 'bg-orange-500', - isPrimary: true, + monitor: { + title: '智能监控', + items: [ + { + id: 'camera-monitor', + icon: Camera, + label: '实时监控', + description: '查看宠物', + color: '#F59E0B', + bgColor: '#FFFBEB', + page: 'camera-monitor', + category: 'monitor', + badge: (deviceStats.online || 0) + '在线', + badgeColor: '#34C759', + isReal: true, + }, + { + id: 'devices', + icon: Activity, + label: '设备管理', + description: '智能设备', + color: '#06B6D4', + bgColor: '#ECFEFF', + page: 'devices', + category: 'monitor', + badge: (deviceStats.total || 0).toString(), + badgeColor: '#06B6D4', + isReal: true, + }, + { + id: 'diet-data', + icon: Flame, + label: '饮食数据', + description: '营养分析', + color: '#EC4899', + bgColor: '#FCE7F3', + page: 'diet-data', + category: 'monitor', + badge: dietStats.totalMeals + '次', + badgeColor: '#EC4899', + isReal: true, + }, + { + id: 'activity', + icon: TrendingUp, + label: '活动追踪', + description: '运动记录', + color: '#14B8A6', + bgColor: '#F0FDFA', + page: 'advanced-health', + category: 'monitor', + isReal: true, + }, + ], }, - { - icon: FileText, - label: '健康记录', - description: '追踪健康状况', - color: '#10B981', - bgGradient: 'from-emerald-500 via-teal-500 to-cyan-500', - page: 'health-records', - isPrimary: false, + care: { + title: '日常护理', + items: [ + { + id: 'records', + icon: Calendar, + label: '日常记录', + description: '时间线', + color: '#F97316', + bgColor: '#FFF7ED', + page: 'records', + category: 'care', + isReal: true, + }, + { + id: 'pet-profile', + icon: PawPrint, + label: '宠物档案', + description: '详细信息', + color: '#A855F7', + bgColor: '#FAF5FF', + page: 'pet-profile', + category: 'care', + isReal: true, + }, + { + id: 'translator', + icon: MessageCircle, + label: '宠物翻译', + description: '理解心声', + color: '#6366F1', + bgColor: '#EEF2FF', + page: 'translator', + category: 'care', + isReal: true, + }, + { + id: 'diet-advice', + icon: Utensils, + label: '饮食建议', + description: '科学喂养', + color: '#84CC16', + bgColor: '#F7FEE7', + page: 'diet-advice', + category: 'care', + isReal: true, + }, + ], }, - { - icon: BookOpen, - label: '健康手册', - description: '专业养宠知识', - color: '#6366F1', - bgGradient: 'from-indigo-500 via-purple-500 to-blue-500', - page: 'health-manual', - badge: '60+篇', - badgeColor: 'bg-indigo-500', - isPrimary: false, + tools: { + title: '实用工具', + items: [ + { + id: 'medical', + icon: Shield, + label: '医疗记录', + description: '就诊历史', + color: '#DC2626', + bgColor: '#FEF2F2', + page: 'medical', + category: 'tools', + isReal: true, + }, + { + id: 'training', + icon: BookOpen, + label: '训练指南', + description: '行为训练', + color: '#0EA5E9', + bgColor: '#F0F9FF', + page: 'training', + category: 'tools', + isReal: true, + }, + { + id: 'insurance', + icon: Shield, + label: '宠物保险', + description: '保障计划', + color: '#7C3AED', + bgColor: '#F5F3FF', + page: 'insurance', + category: 'tools', + isReal: true, + }, + { + id: 'services', + icon: Eye, + label: '周边服务', + description: '宠物服务', + color: '#F59E0B', + bgColor: '#FFFBEB', + page: 'services', + category: 'tools', + isReal: true, + }, + ], }, - { - icon: Calendar, - label: '智能提醒', - description: '重要日程管理', - color: '#F59E0B', - bgGradient: 'from-amber-500 via-yellow-500 to-orange-500', - page: 'reminders', - badge: upcomingReminders.length > 0 ? `${upcomingReminders.length}条` : undefined, - badgeColor: 'bg-amber-500', - isPrimary: false, - }, - ]; - - const getEmotionEmoji = (emotion: string) => { - const map: Record = { - happy: '😸', - curious: '🤔', - anxious: '😰', - angry: '😾', - needs: '🥺', - relaxed: '😌', - excited: '🤩', - sleepy: '😴', - calm: '🧘', - }; - return map[emotion] || '😐'; }; - const getEmotionLabel = (emotion: string) => { - const map: Record = { - happy: '开心', - curious: '好奇', - anxious: '焦虑', - angry: '生气', - needs: '需要关注', - relaxed: '放松', - excited: '兴奋', - sleepy: '困倦', - calm: '平静', - }; - return map[emotion] || '未知'; + // 功能点击动画 + const handleFeatureClick = (feature: FeatureItem, element: HTMLElement) => { + const animator = createAnimation(element); + animator.press(); + + setTimeout(() => { + animator.release(); + onNavigate(feature.page); + }, AnimationConfig.duration.fast); }; - const handleCardClick = useCallback((page: string) => { - onNavigate(page); - }, [onNavigate]); + // 获取宠物头像 + const getPetAvatar = (pet: typeof pets[0]) => { + if (pet.avatar) return pet.avatar; + return pet.type === 'dog' + ? 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=cute%20golden%20retriever%20dog%20portrait&image_size=square' + : 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=cute%20orange%20cat%20portrait&image_size=square'; + }; - const _refreshProgress = Math.min(pullDistance / 60, 1); - const refreshRotation = pullDistance * 2; + // 加载状态 + if (isLoading) { + return ( +
+
+
+
加载中...
+
+
+ ); + } return ( -
-
-
20 || isRefreshing ? 'opacity-100 scale-100' : 'opacity-0 scale-75' - }`} - style={{ - top: isRefreshing ? 10 : -40 + pullDistance * 0.5, +
+ {/* 顶部宠物信息区 */} +
+ {/* 宠物信息卡片 */} +
+
+
+ {currentPet?.name +
+
+
+
+
+

+ {currentPet?.name || 'JOJO'} +

+ + {currentPet?.gender === 'male' ? '♂' : '♀'} + +
+

+ {currentPet?.breed || '柯基犬'} · {currentPet?.age || 2}岁 +

+
+ + 活力充沛 +
+
+ +
+ + {/* 健康评分卡片 */} +
-
- +
+
+
+ +
+
+

健康评分

+

+ {healthScoreData.overall} +

+
+
+
- - {isRefreshing ? '刷新中...' : pullDistance > 60 ? '释放刷新' : '下拉刷新'} -
+
-
- {/* ColorOS 16 液态玻璃背景渐变 */} -
- - {/* 液态玻璃纹理背景 */} -
- - {/* 液态玻璃浮动光斑 */} -
-
-
-
- -
-
-
-

-
- -
- 爪爪连心❤️ -

-

温暖守护 · 陪伴成长

-
-
- {/* 液态玻璃宠物选择器 */} -
- {pets.map((pet, index) => ( + {/* 功能网格 */} +
+ {category.items.map((feature, featureIndex) => ( ))} -
- - {/* 液态玻璃Hero卡片 */} - -
-
-
- {getEmotionEmoji(currentEmotion)} -
-
-
-
-
-
-

{currentPet?.name}

-

{getEmotionLabel(currentEmotion)}

-
-
- - {unlockedBadges}徽章 -
-
- - {streakDays}天 -
-
-
-
-
{healthScore}
-
健康分
-
-
-
-
- -
- {isLoading ? ( - <> - -
-
-
-
-
-
- - - -
-
-
-
-
- - - ) : ( - <> - {onlineCameras.length > 0 && ( - -
-

-
- -
- 在线设备 -

- -
-
- {onlineCameras.slice(0, 2).map((camera, index) => ( - - ))} -
-
- )} - -
-
-

- - 快捷功能 -

-
- -
- {quickActions.map((action, index) => ( - - ))} -
-
+ ))} +
- {upcomingReminders.length > 0 && ( - +
+
+

+ 今日数据概览 +

+ +
+ {[ + { + icon: Utensils, + label: '进食', + value: dietStats.totalMeals, + unit: '次', + color: '#F59E0B', + }, + { + icon: Activity, + label: '活动', + value: 85, + unit: '分钟', + color: '#10B981', + }, + { + icon: Heart, + label: '健康', + value: healthScoreData.overall, + unit: '分', + color: '#EF4444', + }, + { + icon: Zap, + label: '积分', + value: totalPoints, + unit: '', + color: '#8B5CF6', + }, + ].map((stat, index) => ( +
-
-

-
- -
- 即将到来 -

- -
-
- {upcomingReminders.map((reminder, index) => ( -
onNavigate('reminders')} - > -
-
-

{reminder.title}

-

{reminder.date} {reminder.time}

-
- -
- ))} +
+
- - )} - - {recentRecords.length > 0 && ( - -
-

-
- -
- 最近记录 -

- -
-
- {recentRecords.map((record, index) => ( -
onNavigate('health-records')} - > -
-

{record.title}

- {record.createdAt?.split('T')[0]} -
-

{record.content}

-
- ))} -
-
- )} - -
-
-
- - {totalPoints} 积分 -
-
- - {metrics.overall}% 亲密度 + {stat.value}{stat.unit} +

-
- - )} -
+ ))} +
+
+
+
+ + {/* 快捷操作 */} +
+
+ + +
); -}; \ No newline at end of file +}; + +export default HomePage; \ No newline at end of file diff --git a/src/pages/InsurancePage.tsx b/src/pages/InsurancePage.tsx index 427ef4fe..d59617fe 100644 --- a/src/pages/InsurancePage.tsx +++ b/src/pages/InsurancePage.tsx @@ -3,31 +3,166 @@ // // 作者: 带娃的小陈工 // 日期: 2026-05-26 -// 描述: 宠物保险页面 +// 描述: 宠物保险页面 - 完整功能实现 // ============================================ import { useState } from 'react'; -import { Shield, Check } from 'lucide-react'; +import { Shield, Check, FileText, DollarSign, Calendar, AlertCircle, ChevronRight, CreditCard, Clock, Phone, Download, Eye, Plus, X, Upload, Camera, Info } from 'lucide-react'; import { useInsuranceStore } from '../store/insuranceStore'; +import { usePetStore } from '../store/petStore'; +import { Card, Button, Badge, GlassModal } from '../components/DesignSystem'; +import { useResponsiveStyle } from '../lib/responsiveStyle'; export default function InsurancePage() { - const { plans, policies, selectPlan, purchasePolicy } = useInsuranceStore(); + const { plans, policies, claims, selectedPlan, selectPlan, purchasePolicy, submitClaim } = useInsuranceStore(); + const { pets, selectedPetId } = usePetStore(); + const responsive = useResponsiveStyle(); + const [selectedPlanId, setSelectedPlanId] = useState(null); + const [showPurchaseModal, setShowPurchaseModal] = useState(false); + const [showClaimModal, setShowClaimModal] = useState(false); + const [showPolicyDetailModal, setShowPolicyDetailModal] = useState(false); + const [selectedPolicyId, setSelectedPolicyId] = useState(null); + const [activeTab, setActiveTab] = useState<'plans' | 'policies' | 'claims'>('plans'); + const [purchaseStep, setPurchaseStep] = useState(1); + + // 理赔表单状态 + const [claimForm, setClaimForm] = useState({ + policyId: '', + type: 'medical', + amount: 0, + description: '', + date: new Date(), + documents: [] + }); - const handleSelectPlan = async (planId: string) => { + const selectedPet = pets.find(p => p.id === selectedPetId); + const selectedPolicy = policies.find(p => p.id === selectedPolicyId); + const selectedPlanData = plans.find(p => p.id === selectedPlanId); + + const handleSelectPlan = (planId: string) => { setSelectedPlanId(planId); const plan = plans.find(p => p.id === planId); if (plan) { selectPlan(plan); - await purchasePolicy(planId, 'pet-1'); + setShowPurchaseModal(true); + setPurchaseStep(1); + } + }; + + const handlePurchasePolicy = async () => { + if (!selectedPlanId || !selectedPetId) return; + + const success = await purchasePolicy(selectedPlanId, selectedPetId); + if (success) { + setShowPurchaseModal(false); + setSelectedPlanId(null); + setPurchaseStep(1); + setActiveTab('policies'); + } + }; + + const handleSubmitClaim = async () => { + if (!claimForm.policyId || !claimForm.description || claimForm.amount <= 0) { + return; + } + + const success = await submitClaim({ + policyId: claimForm.policyId, + type: claimForm.type, + amount: claimForm.amount, + description: claimForm.description, + date: claimForm.date, + documents: claimForm.documents + }); + + if (success) { + setShowClaimModal(false); + setClaimForm({ + policyId: '', + type: 'medical', + amount: 0, + description: '', + date: new Date(), + documents: [] + }); + setActiveTab('claims'); } }; + const handleViewPolicyDetail = (policyId: string) => { + setSelectedPolicyId(policyId); + setShowPolicyDetailModal(true); + }; + + const handleStartClaim = (policyId: string) => { + setClaimForm(prev => ({ ...prev, policyId })); + setShowClaimModal(true); + }; + + const getClaimStatusLabel = (status: string) => { + const labels: Record = { + submitted: '已提交', + under_review: '审核中', + approved: '已批准', + rejected: '已拒绝' + }; + return labels[status] || status; + }; + + const getClaimStatusColor = (status: string) => { + const colors: Record = { + submitted: 'primary', + under_review: 'warning', + approved: 'success', + rejected: 'danger' + }; + return colors[status] || 'default'; + }; + + const getPolicyStatusLabel = (status: string) => { + const labels: Record = { + active: '有效', + pending: '待生效', + expired: '已过期', + cancelled: '已取消' + }; + return labels[status] || status; + }; + + const getPolicyStatusColor = (status: string) => { + const colors: Record = { + active: 'success', + pending: 'warning', + expired: 'default', + cancelled: 'danger' + }; + return colors[status] || 'default'; + }; + + const getClaimTypeLabel = (type: string) => { + const labels: Record = { + medical: '医疗费用', + accident: '意外伤害', + surgery: '手术费用', + wellness: '健康体检' + }; + return labels[type] || type; + }; + + // 计算统计数据 + const totalCoverage = (policies || []).reduce((sum, p) => { + const plan = (plans || []).find(pl => pl?.id === p?.planId); + return sum + (plan?.annualLimit || 0); + }, 0); + const totalClaimsAmount = (claims || []).reduce((sum, c) => sum + (c?.amount || 0), 0); + const approvedClaimsCount = (claims || []).filter(c => c?.status === 'approved').length; + return (
{/* Header */} -
-
+
+
@@ -37,135 +172,778 @@ export default function InsurancePage() {

为爱宠提供全方位保障

+ + {/* Stats */} +
+
+
保障总额
+
¥{totalCoverage.toLocaleString()}
+
+
+
理赔金额
+
¥{totalClaimsAmount.toLocaleString()}
+
+
+
成功理赔
+
{approvedClaimsCount}
+
+
- {/* My Policies */} - {policies.length > 0 && ( -
-

我的保单

- {policies.map((policy) => ( -
-
-
-

{policy.planName}

-

保障中 · {policy.petName}

-
- - 有效 - -
-
- ¥{policy.premium}/月 - - 到期: {policy.endDate.toLocaleDateString()} - -
-
+ {/* Tabs */} +
+
+ {(['plans', 'policies', 'claims'] as const).map((tab) => ( + ))}
- )} +
- {/* Insurance Plans */} -
-

选择保障方案

-
- {plans.map((plan) => ( -
- {plan.isPopular && ( -
- 🔥 最受欢迎 + {/* Content */} +
+ {/* Plans Tab */} + {activeTab === 'plans' && ( +
+

选择保障方案

+ +
+ {plans.map((plan) => ( + handleSelectPlan(plan.id)} + className={selectedPlanId === plan.id ? 'border-secondary-500 ring-2 ring-secondary-200' : ''} + > + {plan.isPopular && ( +
+ 🔥 最受欢迎 +
+ )} + +
+
+

{plan.name}

+

{plan.description}

+
+
+ +
+ ¥{plan.price} + /月 +
+ + {/* Coverage */} +
+
+ + + 意外保障 + +
+
+ + + 疾病医疗 + +
+
+ + + 健康体检 + +
+
+ + + 手术保障 + +
+
+ + + 急诊服务 + +
+
+ + {/* Features */} +
+ {plan.features.map((feature, idx) => ( + + {feature} + + ))} +
+ + {/* Policy Details */} +
+
+
+

免赔额

+

¥{plan.deductible}

+
+
+

报销比例

+

{plan.reimbursementPercentage}%

+
+
+

年度限额

+

¥{plan.annualLimit.toLocaleString()}

+
+
+
+ + +
+ ))} +
+
+ )} + + {/* Policies Tab */} + {activeTab === 'policies' && ( +
+
+

我的保单

+ +
+ + {policies.length === 0 ? ( + +
+ +

暂无保单

+
+
+ ) : ( +
+ {policies.map((policy) => { + const plan = plans.find(p => p.id === policy.planId); + return ( + +
+
+
+ +
+
+

{policy.planName}

+

{policy.petName}

+
+
+ + {getPolicyStatusLabel(policy.status)} + +
+ +
+
+ + ¥{policy.premium}/月 +
+
+ + 到期: {policy.endDate.toLocaleDateString()} +
+
+ + {plan && ( +
+
+ 年度限额 + ¥{plan.annualLimit.toLocaleString()} +
+
+ 报销比例 + {plan.reimbursementPercentage}% +
+
+ )} + +
+ + +
+
+ ); + })} +
+ )} +
+ )} + + {/* Claims Tab */} + {activeTab === 'claims' && ( +
+
+

理赔记录

+ {policies.length > 0 && ( + )} - -
-
-
-

{plan.name}

-

{plan.description}

+
+ + {claims.length === 0 ? ( + +
+ +

暂无理赔记录

+ {policies.length > 0 && ( + + )} +
+
+ ) : ( +
+ {claims.map((claim) => { + const policy = policies.find(p => p.id === claim.policyId); + return ( + +
+
+
+ +
+
+

{getClaimTypeLabel(claim.type)}

+

{policy?.planName || '未知保单'}

+
+
+ + {getClaimStatusLabel(claim.status)} + +
+ +
+
+ + ¥{claim.amount.toLocaleString()} +
+
+ + {claim.date.toLocaleDateString()} +
+
+ +

+ {claim.description} +

+ + {claim.status === 'under_review' && ( +
+ + 预计审核时间: 3-5个工作日 +
+ )} +
+ ); + })} +
+ )} +
+ )} +
+ + {/* Purchase Modal */} + setShowPurchaseModal(false)} + title="投保确认" + size="lg" + > + {selectedPlanData && ( +
+ {/* Step Indicator */} +
+ {[1, 2, 3].map((step) => ( +
+
= step ? 'bg-secondary-500 text-white' : 'bg-neutral-100 text-neutral-400' + }`}> + {purchaseStep > step ? : step}
+ {step < 3 && ( +
step ? 'bg-secondary-500' : 'bg-neutral-100'}`} /> + )}
+ ))} +
+ + {/* Step 1: Plan Summary */} + {purchaseStep === 1 && ( +
+

保险方案详情

+ + +
+
+ +
+
+
{selectedPlanData.name}
+

{selectedPlanData.description}

+
+
+ +
+ ¥{selectedPlanData.price} + /月 +
-
- ¥{plan.price} - /月 +
+
+

免赔额

+

¥{selectedPlanData.deductible}

+
+
+

报销比例

+

{selectedPlanData.reimbursementPercentage}%

+
+
+

年度限额

+

¥{selectedPlanData.annualLimit.toLocaleString()}

+
+
+ + + {/* Coverage List */} +
+
保障范围
+
+ {Object.entries(selectedPlanData.coverage).map(([key, value]) => ( +
+ + + {key === 'accidents' ? '意外保障' : + key === 'illnesses' ? '疾病医疗' : + key === 'wellness' ? '健康体检' : + key === 'dental' ? '牙科护理' : + key === 'surgery' ? '手术保障' : + key === 'emergency' ? '急诊服务' : key} + +
+ ))} +
+
+ )} - {/* Coverage */} -
-
- - - 意外保障 - + {/* Step 2: Pet Selection */} + {purchaseStep === 2 && ( +
+

选择投保宠物

+ + {selectedPet && ( + +
+
+ 🐾 +
+
+
{selectedPet.name}
+

{selectedPet.species} · {selectedPet.breed}

+
+ +
+
+ )} + + {!selectedPet && pets.length > 0 && ( +
+ {pets.map((pet) => ( + +
+
+ 🐾 +
+
+
{pet.name}
+

{pet.species} · {pet.breed}

+
+
+
+ ))}
-
- - - 疾病医疗 - + )} + + {pets.length === 0 && ( + +
+

请先添加宠物信息

+
+
+ )} +
+ )} + + {/* Step 3: Payment */} + {purchaseStep === 3 && ( +
+

支付确认

+ + +
+
+ 保险方案 + {selectedPlanData.name} +
+
+ 投保宠物 + {selectedPet?.name || '未选择'} +
+
+ 保障期限 + 1年 +
+
+
+ 月缴金额 + ¥{selectedPlanData.price} +
+
+ 年缴总额 + ¥{selectedPlanData.price * 12} +
+
-
- - - 健康体检 - + + + {/* Payment Method */} +
+
支付方式
+
+ +
+ + 微信支付 + +
+
+ +
+ + 支付宝 +
+
-
- - - 手术保障 - +
+ + {/* Terms */} +
+
+ +

+ 投保即表示您同意《宠物保险服务协议》和《隐私政策》。保险生效后,您可随时申请理赔。 +

+
+ )} - {/* Features */} -
- {plan.features.map((feature, idx) => ( - - {feature} - - ))} + {/* Action Buttons */} +
+ {purchaseStep > 1 && ( + + )} + {purchaseStep < 3 ? ( + + ) : ( + + )} +
+
+ )} + + + {/* Claim Modal */} + setShowClaimModal(false)} + title="申请理赔" + size="lg" + > +
+ {/* Policy Info */} + {claimForm.policyId && ( +
+
+ + + 保单: {policies.find(p => p.id === claimForm.policyId)?.planName} + +
+
+ )} + +
+ + +
+ +
+ + setClaimForm(prev => ({ ...prev, date: new Date(e.target.value) }))} + /> +
+ +
+ + setClaimForm(prev => ({ ...prev, amount: Number(e.target.value) }))} + min="0" + /> +
+ +
+ +