diff --git a/PROJECT_DOCUMENTATION.md b/PROJECT_DOCUMENTATION.md new file mode 100644 index 0000000..98a45f6 --- /dev/null +++ b/PROJECT_DOCUMENTATION.md @@ -0,0 +1,445 @@ +# ChronoMail - 时光胶囊邮箱 详细文档 + +## 项目概述 + +ChronoMail是一个基于Vue3和Electron的时光胶囊邮箱应用,用户可以撰写邮件并设定未来的发送时间,将此刻的心情、想法和祝福寄给未来的自己或他人。项目采用深空主题设计,营造时光穿梭的视觉体验。 + +## 技术架构 + +### 前端技术栈 +- **Vue 3**: 使用Composition API进行组件开发 +- **Vue Router 4**: 负责前端路由管理和导航守卫 +- **Vant 4**: 移动端UI组件库,提供丰富的交互组件 +- **Axios**: HTTP客户端,用于API请求和响应处理 +- **Electron**: 跨平台桌面应用框架 + +### 项目结构 +``` +ChronoMail/ +├── public/ # 静态资源 +│ ├── favicon.svg # 网站图标 +│ └── index.html # HTML模板 +├── src/ # 源代码 +│ ├── api/ # API接口模块 +│ │ ├── index.js # API方法集合 +│ │ └── request.js # Axios实例和拦截器配置 +│ ├── assets/ # 静态资源 +│ │ └── styles/ # 样式文件 +│ ├── router/ # 路由配置 +│ │ └── index.js # 路由定义和守卫 +│ ├── store/ # 状态管理 +│ │ └── index.js # 全局状态和操作方法 +│ ├── utils/ # 工具函数 +│ │ └── index.js # 通用工具方法 +│ ├── views/ # 页面组件 +│ │ ├── Login.vue # 登录页 +│ │ ├── Register.vue # 注册页 +│ │ ├── Home.vue # 首页/时光胶囊 +│ │ ├── Compose.vue # 撰写邮件 +│ │ ├── Inbox.vue # 收件箱 +│ │ ├── Sent.vue # 发件箱 +│ │ ├── Profile.vue # 个人中心 +│ │ ├── Timeline.vue # 时间线 +│ │ ├── CapsuleDetail.vue # 胶囊详情 +│ │ └── ApiDemo.vue # API示例 +│ ├── App.vue # 根组件 +│ └── main.js # 应用入口 +├── .env.development # 开发环境变量 +├── .env.production # 生产环境变量 +├── babel.config.js # Babel配置 +├── vue.config.js # Vue CLI配置 +└── package.json # 项目依赖和脚本 +``` + +## 核心功能模块 + +### 1. 用户认证模块 +- **登录功能**: 用户通过邮箱和密码登录系统 +- **注册功能**: 新用户注册账号 +- **Token管理**: 自动刷新和存储访问令牌 +- **路由守卫**: 保护需要登录的页面 + +### 2. 邮件管理模块 +- **撰写邮件**: 创建带有未来发送时间的邮件 +- **收件箱**: 查看接收到的邮件 +- **发件箱**: 查看已发送的邮件 +- **草稿箱**: 管理未发送的邮件草稿 +- **邮件操作**: 编辑、删除、撤销发送等 + +### 3. 时光胶囊模块 +- **3D胶囊视图**: 在深空背景中展示时光胶囊 +- **胶囊交互**: 点击胶囊查看详情 +- **胶囊样式**: 自定义胶囊外观 +- **倒计时显示**: 显示距离发送的时间 + +### 4. AI助手模块 +- **写作辅助**: AI帮助生成邮件开头 +- **内容建议**: 提供邮件内容建议 +- **情感分析**: 分析邮件内容的情感倾向 + +### 5. 个人空间模块 +- **时间线**: 展示用户的邮件历史 +- **数据统计**: 邮件发送/接收统计 +- **个人信息**: 管理用户资料 + +## 页面功能详解 + +### 1. 登录页面 (Login.vue) +- 用户登录表单 +- 记住登录状态选项 +- 跳转注册页面链接 +- 深空主题背景设计 + +### 2. 注册页面 (Register.vue) +- 用户注册表单 +- 密码确认验证 +- 用户协议同意选项 +- 跳转登录页面链接 + +### 3. 首页/时光胶囊 (Home.vue) +- 个性化欢迎语 +- 3D时光胶囊展示 +- 星空动态背景 +- 搜索和通知功能 +- 底部导航栏 +- 悬浮撰写按钮 + +### 4. 撰写邮件 (Compose.vue) +- 收件人类型选择(自己/指定/公开) +- 发送时间选择(预设时间/自定义时间) +- 邮件标题和内容编辑 +- 附件上传功能 +- AI写作辅助 +- 胶囊样式选择 +- 存入草稿和发送按钮 + +### 5. 收件箱 (Inbox.vue) +- 邮件列表展示 +- 邮件状态筛选 +- 邮件详情查看 +- 搜索功能 + +### 6. 发件箱 (Sent.vue) +- 已发送邮件列表 +- 邮件状态显示 +- 撤销发送功能 +- 邮件详情查看 + +### 7. 个人中心 (Profile.vue) +- 用户信息展示 +- 订阅信息显示 +- 功能设置选项 +- 退出登录功能 + +### 8. 时间线 (Timeline.vue) +- 按时间顺序展示邮件 +- 邮件情感标记 +- 月份统计图表 + +### 9. 胶囊详情 (CapsuleDetail.vue) +- 邮件完整内容展示 +- 发送/接收信息 +- 附件查看 +- 相关操作按钮 + +## 状态管理 + +项目使用Vue 3的响应式API进行状态管理,主要状态包括: + +### 用户状态 (userState) +```javascript +{ + isLoggedIn: boolean, // 登录状态 + token: string, // 访问令牌 + refreshToken: string, // 刷新令牌 + userInfo: { // 用户信息 + userId: string, + username: string, + email: string, + avatar: string + }, + subscription: { // 订阅信息 + plan: string, + remainingMails: number, + features: object + } +} +``` + +### 邮件状态 (mailState) +```javascript +{ + inboxList: [], // 收件箱邮件列表 + sentList: [], // 发件箱邮件列表 + draftList: [], // 草稿箱邮件列表 + currentMail: {}, // 当前查看的邮件 + totalCounts: { // 各类邮件计数 + inbox: number, + sent: number, + draft: number + } +} +``` + +### 胶囊状态 (capsuleState) +```javascript +{ + capsules: [], // 时光胶囊列表 + scene: string, // 场景类型 + background: string // 背景配置 +} +``` + +### 时间线状态 (timelineState) +```javascript +{ + timeline: [], // 时间线数据 + statistics: {} // 统计数据 +} +``` + +## API接口设计 + +### 认证相关 +- `POST /api/v1/auth/register` - 用户注册 +- `POST /api/v1/auth/login` - 用户登录 +- `POST /api/v1/auth/refresh` - 刷新令牌 +- `POST /api/v1/auth/logout` - 退出登录 + +### 邮件管理 +- `GET /api/v1/mails` - 获取邮件列表 +- `POST /api/v1/mails` - 创建邮件 +- `GET /api/v1/mails/{id}` - 获取邮件详情 +- `PUT /api/v1/mails/{id}` - 更新邮件 +- `DELETE /api/v1/mails/{id}` - 删除邮件 +- `POST /api/v1/mails/{id}/revoke` - 撤销发送 + +### 时光胶囊 +- `GET /api/v1/capsules` - 获取胶囊视图 +- `PUT /api/v1/capsules/{id}/style` - 更新胶囊样式 + +### AI助手 +- `POST /api/v1/ai/writing-assistant` - 写作辅助 +- `POST /api/v1/ai/sentiment-analysis` - 情感分析 + +### 个人空间 +- `GET /api/v1/timeline` - 获取时间线 +- `GET /api/v1/statistics` - 获取统计数据 +- `GET /api/v1/user/profile` - 获取用户信息 + +### 文件上传 +- `POST /api/v1/upload/attachment` - 上传附件 +- `POST /api/v1/upload/avatar` - 上传头像 + +## 环境配置 + +### 开发环境 (.env.development) +``` +VUE_APP_API_BASE_URL=http://localhost:3000/api/v1 +VUE_APP_TITLE=ChronoMail - 未来邮箱 +``` + +### 生产环境 (.env.production) +``` +VUE_APP_API_BASE_URL=https://api.chronomail.com/api/v1 +VUE_APP_TITLE=ChronoMail - 未来邮箱 +``` + +## 开发指南 + +### 1. 安装依赖 +```bash +npm install +``` + +### 2. 启动开发服务器 +```bash +npm run serve +``` + +### 3. 构建生产版本 +```bash +npm run build +``` + +### 4. 运行Electron应用 +```bash +npm run electron:serve +``` + +### 5. 代码检查 +```bash +npm run lint +``` + +## 组件开发规范 + +### 1. 组件结构 +```vue + + + + + + + +``` + +### 2. 状态管理 +- 使用响应式API创建状态 +- 通过操作方法修改状态 +- 避免直接修改状态,使用提供的操作方法 + +### 3. API调用 +- 使用统一的API模块 +- 处理加载状态和错误情况 +- 使用async/await处理异步操作 + +## 样式设计 + +### 1. 主题色彩 +- 主色调:深空蓝 (#0A0E27) +- 强调色:星云紫 (#6A5ACD) +- 点缀色:星光蓝 (#4285F4) +- 背景色:深空黑 (#050714) + +### 2. 字体规范 +- 主标题:24px,粗体 +- 副标题:18px,中等 +- 正文:14px,常规 +- 辅助文本:12px,常规 + +### 3. 间距规范 +- 大间距:24px +- 中间距:16px +- 小间距:8px +- 微间距:4px + +### 4. 组件样式 +- 使用Vant组件库作为基础 +- 通过CSS变量覆盖默认样式 +- 使用scoped样式避免污染 + +## 部署说明 + +### 1. Web应用部署 +1. 执行构建命令:`npm run build` +2. 将dist目录上传至Web服务器 +3. 配置服务器路由重定向 + +### 2. Electron应用打包 +1. 安装electron-builder:`npm install electron-builder --save-dev` +2. 配置package.json的build字段 +3. 执行打包命令:`npm run electron:build` + +## 性能优化 + +### 1. 代码分割 +- 使用路由懒加载 +- 按需加载组件 +- 分离第三方库 + +### 2. 资源优化 +- 图片压缩和懒加载 +- 字体文件优化 +- CSS代码压缩 + +### 3. 缓存策略 +- 静态资源长期缓存 +- API响应缓存 +- 离线数据缓存 + +## 测试策略 + +### 1. 单元测试 +- 组件渲染测试 +- 方法逻辑测试 +- 状态管理测试 + +### 2. 集成测试 +- 页面跳转测试 +- API调用测试 +- 用户交互测试 + +### 3. 端到端测试 +- 关键流程测试 +- 跨浏览器兼容性测试 +- 移动端适配测试 + +## 常见问题 + +### 1. API请求失败 +- 检查网络连接 +- 确认API地址配置 +- 验证Token有效性 + +### 2. 页面加载缓慢 +- 检查资源大小 +- 优化图片资源 +- 启用Gzip压缩 + +### 3. 移动端适配问题 +- 检查视口设置 +- 调整字体大小 +- 优化触摸交互 + +## 未来规划 + +### 1. 功能扩展 +- 多媒体邮件支持 +- 群组邮件功能 +- 邮件模板库 + +### 2. 技术升级 +- 迁移到Vite构建工具 +- 引入TypeScript +- 实现PWA功能 + +### 3. 用户体验优化 +- 个性化主题 +- 智能推荐系统 +- 社交分享功能 + +## 联系方式 + +如有问题或建议,请联系开发团队。 + +--- + +*最后更新时间:2024年6月* \ No newline at end of file diff --git a/src/assets/styles/global.css b/src/assets/styles/global.css index 9e895ef..d85eeb2 100644 --- a/src/assets/styles/global.css +++ b/src/assets/styles/global.css @@ -141,14 +141,316 @@ body { } /* 页面过渡动画 */ +.page-enter-active, .page-leave-active { + transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.page-enter-from { + opacity: 0; + transform: translateX(30px) scale(0.95); +} + +.page-leave-to { + opacity: 0; + transform: translateX(-30px) scale(0.95); +} + +/* 淡入淡出过渡 */ .fade-enter-active, .fade-leave-active { - transition: opacity 0.5s; + transition: opacity 0.5s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } +/* 滑动过渡 */ +.slide-up-enter-active, .slide-up-leave-active { + transition: all 0.3s ease; +} + +.slide-up-enter-from { + opacity: 0; + transform: translateY(30px); +} + +.slide-up-leave-to { + opacity: 0; + transform: translateY(-30px); +} + +.slide-down-enter-active, .slide-down-leave-active { + transition: all 0.3s ease; +} + +.slide-down-enter-from { + opacity: 0; + transform: translateY(-30px); +} + +.slide-down-leave-to { + opacity: 0; + transform: translateY(30px); +} + +/* 缩放过渡 */ +.scale-enter-active, .scale-leave-active { + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.scale-enter-from, .scale-leave-to { + opacity: 0; + transform: scale(0.8); +} + +/* 弹性过渡 */ +.bounce-enter-active { + animation: bounce-in 0.6s; +} + +.bounce-leave-active { + animation: bounce-in 0.6s reverse; +} + +@keyframes bounce-in { + 0% { + transform: scale(0); + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } +} + +/* 翻转过渡 */ +.flip-enter-active { + animation: flip-in 0.6s; +} + +.flip-leave-active { + animation: flip-out 0.6s; +} + +@keyframes flip-in { + from { + transform: rotateY(90deg); + opacity: 0; + } + to { + transform: rotateY(0); + opacity: 1; + } +} + +@keyframes flip-out { + from { + transform: rotateY(0); + opacity: 1; + } + to { + transform: rotateY(-90deg); + opacity: 0; + } +} + +/* 旋转过渡 */ +.rotate-enter-active { + animation: rotate-in 0.5s; +} + +.rotate-leave-active { + animation: rotate-out 0.5s; +} + +@keyframes rotate-in { + from { + transform: rotate(-180deg) scale(0.8); + opacity: 0; + } + to { + transform: rotate(0) scale(1); + opacity: 1; + } +} + +@keyframes rotate-out { + from { + transform: rotate(0) scale(1); + opacity: 1; + } + to { + transform: rotate(180deg) scale(0.8); + opacity: 0; + } +} + +/* 交错动画 */ +.stagger-item { + opacity: 0; + animation: stagger-fade-in 0.5s forwards; +} + +@keyframes stagger-fade-in { + to { + opacity: 1; + } +} + +/* 波纹效果 */ +.ripple { + position: relative; + overflow: hidden; +} + +.ripple::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.5); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; +} + +.ripple:active::before { + width: 300px; + height: 300px; +} + +/* 呼吸效果 */ +.breathing { + animation: breathing 3s infinite ease-in-out; +} + +@keyframes breathing { + 0%, 100% { + transform: scale(1); + opacity: 0.8; + } + 50% { + transform: scale(1.05); + opacity: 1; + } +} + +/* 摇摆效果 */ +.wobble { + animation: wobble 1s ease-in-out; +} + +@keyframes wobble { + 0%, 100% { + transform: translateX(0); + } + 15% { + transform: translateX(-5px) rotate(-5deg); + } + 30% { + transform: translateX(5px) rotate(5deg); + } + 45% { + transform: translateX(-5px) rotate(-5deg); + } + 60% { + transform: translateX(5px) rotate(5deg); + } + 75% { + transform: translateX(-5px) rotate(-5deg); + } + 90% { + transform: translateX(5px) rotate(5deg); + } +} + +/* 悬浮效果 */ +.hover-lift { + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.hover-lift:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2); +} + +/* 发光效果 */ +.glow { + transition: box-shadow 0.3s ease; +} + +.glow:hover { + box-shadow: 0 0 15px rgba(0, 212, 255, 0.7); +} + +/* 脉冲效果 */ +.pulse { + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(0, 212, 255, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(0, 212, 255, 0); + } +} + +/* 闪烁效果 */ +.flash { + animation: flash 2s infinite; +} + +@keyframes flash { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* 渐变背景动画 */ +.gradient-bg { + background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); + background-size: 400% 400%; + animation: gradient-shift 15s ease infinite; +} + +@keyframes gradient-shift { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +/* 形状变换动画 */ +.morph { + animation: morph 8s ease-in-out infinite; +} + +@keyframes morph { + 0%, 100% { + border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%; + } + 50% { + border-radius: 30% 60% 70% 40% / 50% 60% 30% 60%; + } +} + +/* 滑动淡入过渡 */ .slide-fade-enter-active { transition: all 0.3s ease-out; } @@ -163,6 +465,573 @@ body { opacity: 0; } +/* 3D翻转效果 */ +.flip-3d { + perspective: 1000px; +} + +.flip-3d-inner { + transition: transform 0.6s; + transform-style: preserve-3d; +} + +.flip-3d:hover .flip-3d-inner { + transform: rotateY(180deg); +} + +.flip-3d-front, .flip-3d-back { + position: absolute; + width: 100%; + height: 100%; + backface-visibility: hidden; +} + +.flip-3d-back { + transform: rotateY(180deg); +} + +/* 立方体效果 */ +.cube-container { + perspective: 1000px; + width: 100px; + height: 100px; +} + +.cube { + width: 100%; + height: 100%; + position: relative; + transform-style: preserve-3d; + transition: transform 1s; +} + +.cube-face { + position: absolute; + width: 100px; + height: 100px; + border: 2px solid rgba(0, 212, 255, 0.5); + background: rgba(0, 20, 40, 0.7); + backdrop-filter: blur(10px); +} + +.cube-face-front { transform: translateZ(50px); } +.cube-face-back { transform: rotateY(180deg) translateZ(50px); } +.cube-face-right { transform: rotateY(90deg) translateZ(50px); } +.cube-face-left { transform: rotateY(-90deg) translateZ(50px); } +.cube-face-top { transform: rotateX(90deg) translateZ(50px); } +.cube-face-bottom { transform: rotateX(-90deg) translateZ(50px); } + +.cube-container:hover .cube { + transform: rotateX(45deg) rotateY(45deg); +} + +/* 震动效果 */ +.shake { + animation: shake 0.5s; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); } + 20%, 40%, 60%, 80% { transform: translateX(10px); } +} + +/* 滚动显示动画 */ +.scroll-reveal { + opacity: 0; + transform: translateY(30px); + transition: opacity 0.6s ease, transform 0.6s ease; +} + +.scroll-reveal.active { + opacity: 1; + transform: translateY(0); +} + +/* 打字机效果 */ +.typewriter { + overflow: hidden; + white-space: nowrap; + animation: typing 3.5s steps(40, end); +} + +@keyframes typing { + from { width: 0 } + to { width: 100% } +} + +/* 弹跳进入效果 */ +.bounce-in { + animation: bounceIn 0.8s; +} + +@keyframes bounceIn { + 0% { + opacity: 0; + transform: scale(0.3); + } + 50% { + opacity: 1; + transform: scale(1.05); + } + 70% { + transform: scale(0.9); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +/* 缩放旋转进入 */ +.zoom-rotate-in { + animation: zoomRotateIn 0.6s; +} + +@keyframes zoomRotateIn { + from { + opacity: 0; + transform: scale(0) rotate(180deg); + } + to { + opacity: 1; + transform: scale(1) rotate(0); + } +} + +/* 滑动门效果 */ +.sliding-door { + position: relative; + overflow: hidden; +} + +.sliding-door::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); + transition: left 0.5s; +} + +.sliding-door:hover::before { + left: 100%; +} + +/* 液体填充效果 */ +.liquid-fill { + position: relative; + overflow: hidden; +} + +.liquid-fill::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 0; + background: linear-gradient(to top, rgba(0, 212, 255, 0.3), transparent); + transition: height 0.5s ease; +} + +.liquid-fill:hover::after { + height: 100%; +} + +/* 边框动画 */ +.border-animation { + position: relative; +} + +.border-animation::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 2px solid transparent; + border-radius: inherit; + transition: border-color 0.3s; +} + +.border-animation:hover::before { + border-color: rgba(0, 212, 255, 0.8); + animation: borderRotate 2s linear infinite; +} + +@keyframes borderRotate { + 0% { + border-top-color: rgba(0, 212, 255, 0.8); + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + } + 25% { + border-top-color: transparent; + border-right-color: rgba(0, 212, 255, 0.8); + border-bottom-color: transparent; + border-left-color: transparent; + } + 50% { + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: rgba(0, 212, 255, 0.8); + border-left-color: transparent; + } + 75% { + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: rgba(0, 212, 255, 0.8); + } + 100% { + border-top-color: rgba(0, 212, 255, 0.8); + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + } +} + +/* 文字闪烁效果 */ +.text-glow { + animation: textGlow 2s infinite alternate; +} + +@keyframes textGlow { + from { + text-shadow: 0 0 10px #fff, 0 0 20px #fff, 0 0 30px #e60073, 0 0 40px #e60073; + } + to { + text-shadow: 0 0 20px #fff, 0 0 30px #ff4da6, 0 0 40px #ff4da6, 0 0 50px #ff4da6; + } +} + +/* 磁性吸附效果 */ +.magnetic { + transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +/* 粒子爆炸效果 */ +.particle-explosion { + position: relative; +} + +.particle { + position: absolute; + width: 4px; + height: 4px; + border-radius: 50%; + background: rgba(0, 212, 255, 0.8); + opacity: 0; +} + +.particle-explosion.active .particle { + animation: particleExplode 1s forwards; +} + +@keyframes particleExplode { + 0% { + transform: translate(0, 0); + opacity: 1; + } + 100% { + transform: translate(var(--tx), var(--ty)); + opacity: 0; + } +} + +/* 光线扫描效果 */ +.scan-line { + position: relative; + overflow: hidden; +} + +.scan-line::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.8), transparent); + animation: scan 3s infinite; +} + +@keyframes scan { + 0% { + top: 0; + } + 50% { + top: calc(100% - 2px); + } + 100% { + top: 0; + } +} + +/* 移动端触摸交互优化 */ +/* 触摸反馈 */ +.touch-feedback { + position: relative; + overflow: hidden; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +.touch-feedback::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + transform: translate(-50%, -50%); + transition: width 0.4s, height 0.4s, opacity 0.4s; + pointer-events: none; +} + +.touch-feedback.active::after { + width: 200px; + height: 200px; + opacity: 0; +} + +/* 触摸滑动支持 */ +.swipe-container { + touch-action: pan-x; + overflow-x: hidden; + position: relative; +} + +.swipe-container.vertical { + touch-action: pan-y; + overflow-x: hidden; + overflow-y: hidden; +} + +.swipe-item { + transition: transform 0.3s ease-out; +} + +/* 移动端按钮优化 */ +.mobile-button { + min-height: 44px; /* iOS推荐的最小触摸目标尺寸 */ + min-width: 44px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + -webkit-tap-highlight-color: transparent; +} + +/* 移动端卡片优化 */ +.mobile-card { + margin: 8px 0; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: transform 0.2s, box-shadow 0.2s; + -webkit-tap-highlight-color: transparent; +} + +.mobile-card:active { + transform: scale(0.98); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); +} + +/* 移动端列表项优化 */ +.mobile-list-item { + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + transition: background-color 0.2s; + -webkit-tap-highlight-color: transparent; +} + +.mobile-list-item:active { + background-color: rgba(0, 212, 255, 0.1); +} + +/* 移动端输入框优化 */ +.mobile-input { + padding: 12px 16px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.2); + background-color: rgba(255, 255, 255, 0.05); + font-size: 16px; /* 防止iOS缩放 */ + -webkit-appearance: none; + -webkit-tap-highlight-color: transparent; +} + +/* 移动端滚动优化 */ +.mobile-scroll { + -webkit-overflow-scrolling: touch; + overflow-scrolling: touch; + scroll-behavior: smooth; +} + +/* 移动端模态框优化 */ +.mobile-modal { + padding: 20px; + max-height: 80vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +/* 移动端标签页优化 */ +.mobile-tab { + padding: 10px 16px; + font-size: 14px; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + -webkit-tap-highlight-color: transparent; +} + +/* 移动端下拉菜单优化 */ +.mobile-dropdown { + min-height: 44px; + padding: 10px 16px; + -webkit-tap-highlight-color: transparent; +} + +/* 移动端开关优化 */ +.mobile-switch { + min-width: 51px; + min-height: 31px; + -webkit-tap-highlight-color: transparent; +} + +/* 移动端滑块优化 */ +.mobile-slider { + height: 44px; + padding: 0 16px; + -webkit-tap-highlight-color: transparent; +} + +/* 移动端手势提示 */ +.swipe-hint { + position: relative; + overflow: hidden; +} + +.swipe-hint::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 30px; + height: 100%; + background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent); + opacity: 0.7; +} + +.swipe-hint::after { + content: '→'; + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + color: rgba(255, 255, 255, 0.7); + font-size: 18px; + animation: swipeHint 2s infinite; +} + +@keyframes swipeHint { + 0%, 100% { + opacity: 0.7; + transform: translateY(-50%) translateX(0); + } + 50% { + opacity: 0.3; + transform: translateY(-50%) translateX(-5px); + } +} + +/* 移动端长按效果 */ +.long-press { + position: relative; + -webkit-tap-highlight-color: transparent; +} + +.long-press.active { + animation: longPress 0.5s ease-out; +} + +@keyframes longPress { + 0% { + transform: scale(1); + } + 50% { + transform: scale(0.95); + } + 100% { + transform: scale(1); + } +} + +/* 移动端弹性滚动 */ +.elastic-scroll { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +} + +/* 移动端安全区域适配 */ +.safe-area-top { + padding-top: constant(safe-area-inset-top); + padding-top: env(safe-area-inset-top); +} + +.safe-area-bottom { + padding-bottom: constant(safe-area-inset-bottom); + padding-bottom: env(safe-area-inset-bottom); +} + +.safe-area-left { + padding-left: constant(safe-area-inset-left); + padding-left: env(safe-area-inset-left); +} + +.safe-area-right { + padding-right: constant(safe-area-inset-right); + padding-right: env(safe-area-inset-right); +} + +/* 移动端横屏适配 */ +@media screen and (orientation: landscape) and (max-height: 500px) { + .mobile-modal { + max-height: 90vh; + } + + .mobile-card { + margin: 4px 0; + } +} + +/* 移动端小屏幕适配 */ +@media screen and (max-width: 375px) { + .mobile-card { + margin: 6px 0; + border-radius: 10px; + } + + .mobile-list-item { + padding: 10px 14px; + } + + .mobile-button { + min-height: 40px; + min-width: 40px; + } +} + /* 自定义滚动条 */ ::-webkit-scrollbar { width: 8px; diff --git a/src/main.js b/src/main.js index 1b7ac20..a9b1027 100644 --- a/src/main.js +++ b/src/main.js @@ -15,10 +15,14 @@ import './assets/styles/global.css' // 引入状态管理 import { userActions } from './store' +// 引入移动端触摸交互插件 +import touchPlugin from './plugins/touch' + const app = createApp(App) app.use(router) app.use(Vant) +app.use(touchPlugin) // 初始化用户状态 userActions.initUserState() diff --git a/src/plugins/touch.js b/src/plugins/touch.js new file mode 100644 index 0000000..fd9f80c --- /dev/null +++ b/src/plugins/touch.js @@ -0,0 +1,16 @@ +import { touchDirectives } from './touch'; + +/** + * 移动端触摸交互Vue插件 + */ +export default { + install(app) { + // 注册所有触摸指令 + Object.keys(touchDirectives).forEach(key => { + app.directive(key, touchDirectives[key]); + }); + } +}; + +// 单独导出指令,以便按需使用 +export { touchDirectives }; \ No newline at end of file diff --git a/src/utils/touch.js b/src/utils/touch.js new file mode 100644 index 0000000..9a9090a --- /dev/null +++ b/src/utils/touch.js @@ -0,0 +1,392 @@ +/** + * 移动端触摸交互工具函数 + */ + +// 触摸反馈效果 +export function addTouchFeedback(element, callback) { + if (!element) return; + + const handleTouchStart = (e) => { + element.classList.add('active'); + }; + + const handleTouchEnd = (e) => { + setTimeout(() => { + element.classList.remove('active'); + if (callback) callback(e); + }, 150); + }; + + element.addEventListener('touchstart', handleTouchStart, { passive: true }); + element.addEventListener('touchend', handleTouchEnd, { passive: true }); + element.addEventListener('touchcancel', handleTouchEnd, { passive: true }); + + // 返回清理函数 + return () => { + element.removeEventListener('touchstart', handleTouchStart); + element.removeEventListener('touchend', handleTouchEnd); + element.removeEventListener('touchcancel', handleTouchEnd); + }; +} + +// 滑动手势检测 +export function detectSwipe(element, options = {}) { + if (!element) return; + + const { + onSwipeLeft, + onSwipeRight, + onSwipeUp, + onSwipeDown, + threshold = 50, // 最小滑动距离 + restraint = 100, // 最大垂直方向移动距离 + allowedTime = 300 // 最大滑动时间 + } = options; + + let touchObject = {}; + + const handleTouchStart = (e) => { + touchObject = { + startX: e.touches[0].pageX, + startY: e.touches[0].pageY, + startTime: new Date().getTime() + }; + }; + + const handleTouchMove = (e) => { + // 可以在这里添加实时滑动效果 + }; + + const handleTouchEnd = (e) => { + if (!touchObject.startTime) return; + + const elapsedTime = new Date().getTime() - touchObject.startTime; + const distX = e.changedTouches[0].pageX - touchObject.startX; + const distY = e.changedTouches[0].pageY - touchObject.startY; + + if (elapsedTime <= allowedTime) { + if (Math.abs(distX) >= threshold && Math.abs(distY) <= restraint) { + // 水平滑动 + if (distX > 0 && onSwipeRight) { + onSwipeRight(); + } else if (distX < 0 && onSwipeLeft) { + onSwipeLeft(); + } + } else if (Math.abs(distY) >= threshold && Math.abs(distX) <= restraint) { + // 垂直滑动 + if (distY > 0 && onSwipeDown) { + onSwipeDown(); + } else if (distY < 0 && onSwipeUp) { + onSwipeUp(); + } + } + } + + touchObject = {}; + }; + + element.addEventListener('touchstart', handleTouchStart, { passive: true }); + element.addEventListener('touchmove', handleTouchMove, { passive: true }); + element.addEventListener('touchend', handleTouchEnd, { passive: true }); + + // 返回清理函数 + return () => { + element.removeEventListener('touchstart', handleTouchStart); + element.removeEventListener('touchmove', handleTouchMove); + element.removeEventListener('touchend', handleTouchEnd); + }; +} + +// 长按检测 +export function detectLongPress(element, callback, duration = 500) { + if (!element) return; + + let pressTimer; + + const handleTouchStart = (e) => { + pressTimer = setTimeout(() => { + element.classList.add('active'); + if (callback) callback(e); + }, duration); + }; + + const handleTouchEnd = (e) => { + clearTimeout(pressTimer); + setTimeout(() => { + element.classList.remove('active'); + }, 150); + }; + + const handleTouchMove = (e) => { + clearTimeout(pressTimer); + }; + + element.addEventListener('touchstart', handleTouchStart, { passive: true }); + element.addEventListener('touchend', handleTouchEnd, { passive: true }); + element.addEventListener('touchmove', handleTouchMove, { passive: true }); + + // 返回清理函数 + return () => { + clearTimeout(pressTimer); + element.removeEventListener('touchstart', handleTouchStart); + element.removeEventListener('touchend', handleTouchEnd); + element.removeEventListener('touchmove', handleTouchMove); + }; +} + +// 惯性滚动 +export function enableInertiaScroll(element) { + if (!element) return; + + let isScrolling = false; + let startY = 0; + let scrollTop = 0; + let velocity = 0; + let rafId = null; + let lastTime = 0; + let lastY = 0; + + const handleTouchStart = (e) => { + isScrolling = true; + startY = e.touches[0].pageY; + scrollTop = element.scrollTop; + velocity = 0; + lastTime = Date.now(); + lastY = startY; + + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; + + const handleTouchMove = (e) => { + if (!isScrolling) return; + + const currentY = e.touches[0].pageY; + const currentTime = Date.now(); + const deltaTime = currentTime - lastTime; + const deltaY = currentY - lastY; + + // 计算速度 + if (deltaTime > 0) { + velocity = deltaY / deltaTime; + } + + element.scrollTop = scrollTop - (currentY - startY); + + lastTime = currentTime; + lastY = currentY; + }; + + const handleTouchEnd = () => { + if (!isScrolling) return; + + isScrolling = false; + + // 应用惯性滚动 + if (Math.abs(velocity) > 0.1) { + const inertiaScroll = () => { + velocity *= 0.95; // 摩擦系数 + + if (Math.abs(velocity) > 0.01) { + element.scrollTop -= velocity * 16; // 假设60fps,每帧约16ms + rafId = requestAnimationFrame(inertiaScroll); + } + }; + + rafId = requestAnimationFrame(inertiaScroll); + } + }; + + element.addEventListener('touchstart', handleTouchStart, { passive: true }); + element.addEventListener('touchmove', handleTouchMove, { passive: true }); + element.addEventListener('touchend', handleTouchEnd, { passive: true }); + + // 返回清理函数 + return () => { + if (rafId) { + cancelAnimationFrame(rafId); + } + element.removeEventListener('touchstart', handleTouchStart); + element.removeEventListener('touchmove', handleTouchMove); + element.removeEventListener('touchend', handleTouchEnd); + }; +} + +// 双击检测 +export function detectDoubleTap(element, callback, timeout = 300) { + if (!element) return; + + let lastTapTime = 0; + + const handleTouchEnd = (e) => { + const currentTime = Date.now(); + const tapLength = currentTime - lastTapTime; + + if (tapLength < timeout && tapLength > 0) { + if (callback) callback(e); + e.preventDefault(); + } + + lastTapTime = currentTime; + }; + + element.addEventListener('touchend', handleTouchEnd, { passive: false }); + + // 返回清理函数 + return () => { + element.removeEventListener('touchend', handleTouchEnd); + }; +} + +// 捏合缩放 +export function detectPinchZoom(element, options = {}) { + if (!element) return; + + const { + onZoomIn, + onZoomOut, + onZoomChange, + minScale = 0.5, + maxScale = 3, + scaleStep = 0.1 + } = options; + + let initialDistance = 0; + let currentScale = 1; + + const handleTouchStart = (e) => { + if (e.touches.length === 2) { + initialDistance = getDistance(e.touches[0], e.touches[1]); + e.preventDefault(); + } + }; + + const handleTouchMove = (e) => { + if (e.touches.length === 2) { + const currentDistance = getDistance(e.touches[0], e.touches[1]); + + if (initialDistance > 0) { + const scale = currentDistance / initialDistance; + currentScale = Math.min(Math.max(1 + (scale - 1) * scaleStep, minScale), maxScale); + + if (onZoomChange) { + onZoomChange(currentScale); + } + } + + e.preventDefault(); + } + }; + + const handleTouchEnd = (e) => { + if (currentScale > 1 && onZoomIn) { + onZoomIn(); + } else if (currentScale < 1 && onZoomOut) { + onZoomOut(); + } + + initialDistance = 0; + currentScale = 1; + }; + + // 计算两点之间的距离 + function getDistance(touch1, touch2) { + const dx = touch1.pageX - touch2.pageX; + const dy = touch1.pageY - touch2.pageY; + return Math.sqrt(dx * dx + dy * dy); + } + + element.addEventListener('touchstart', handleTouchStart, { passive: false }); + element.addEventListener('touchmove', handleTouchMove, { passive: false }); + element.addEventListener('touchend', handleTouchEnd, { passive: true }); + + // 返回清理函数 + return () => { + element.removeEventListener('touchstart', handleTouchStart); + element.removeEventListener('touchmove', handleTouchMove); + element.removeEventListener('touchend', handleTouchEnd); + }; +} + +// Vue自定义指令 +export const touchDirectives = { + // 触摸反馈指令 + 'touch-feedback': { + mounted(el, binding) { + const cleanup = addTouchFeedback(el, binding.value); + el._cleanup = cleanup; + }, + unmounted(el) { + if (el._cleanup) { + el._cleanup(); + } + } + }, + + // 滑动指令 + 'swipe': { + mounted(el, binding) { + const cleanup = detectSwipe(el, binding.value); + el._cleanup = cleanup; + }, + unmounted(el) { + if (el._cleanup) { + el._cleanup(); + } + } + }, + + // 长按指令 + 'long-press': { + mounted(el, binding) { + const cleanup = detectLongPress(el, binding.value); + el._cleanup = cleanup; + }, + unmounted(el) { + if (el._cleanup) { + el._cleanup(); + } + } + }, + + // 惯性滚动指令 + 'inertia-scroll': { + mounted(el) { + const cleanup = enableInertiaScroll(el); + el._cleanup = cleanup; + }, + unmounted(el) { + if (el._cleanup) { + el._cleanup(); + } + } + }, + + // 双击指令 + 'double-tap': { + mounted(el, binding) { + const cleanup = detectDoubleTap(el, binding.value); + el._cleanup = cleanup; + }, + unmounted(el) { + if (el._cleanup) { + el._cleanup(); + } + } + }, + + // 捏合缩放指令 + 'pinch-zoom': { + mounted(el, binding) { + const cleanup = detectPinchZoom(el, binding.value); + el._cleanup = cleanup; + }, + unmounted(el) { + if (el._cleanup) { + el._cleanup(); + } + } + } +}; \ No newline at end of file diff --git a/src/views/Compose.vue b/src/views/Compose.vue index 96108b3..7a403b7 100644 --- a/src/views/Compose.vue +++ b/src/views/Compose.vue @@ -14,32 +14,57 @@
{{ capsule.title }}
-{{ formatDate(capsule.deliveryDate) }}
+ +{{ capsule.title }}
+{{ formatDate(capsule.deliveryDate) }}
+