初始化
This commit is contained in:
3
.env.development
Normal file
3
.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
# 开发环境配置
|
||||
VUE_APP_API_BASE_URL=/api/v1
|
||||
VUE_APP_TITLE=ChronoMail - 未来邮箱
|
||||
3
.env.production
Normal file
3
.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
# 生产环境配置
|
||||
VUE_APP_API_BASE_URL=https://api.chronomail.com/api/v1
|
||||
VUE_APP_TITLE=ChronoMail - 未来邮箱
|
||||
20
.eslintrc.js
Normal file
20
.eslintrc.js
Normal file
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended'
|
||||
],
|
||||
parserOptions: {
|
||||
parser: '@babel/eslint-parser'
|
||||
},
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'no-case-declarations': 'off'
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
422
.trae/rules/project_rules.md
Normal file
422
.trae/rules/project_rules.md
Normal file
@@ -0,0 +1,422 @@
|
||||
|
||||
|
||||
## 1. 用户认证模块
|
||||
|
||||
### 1.1 用户注册
|
||||
```typescript
|
||||
POST /api/v1/auth/register
|
||||
入参:
|
||||
{
|
||||
"username": "string", // 用户名
|
||||
"email": "string", // 邮箱
|
||||
"password": "string", // 密码
|
||||
"avatar": "string?" // 头像URL(可选)
|
||||
}
|
||||
|
||||
出参:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"userId": "string",
|
||||
"username": "string",
|
||||
"email": "string",
|
||||
"avatar": "string",
|
||||
"token": "string",
|
||||
"refreshToken": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 用户登录
|
||||
```typescript
|
||||
POST /api/v1/auth/login
|
||||
入参:
|
||||
{
|
||||
"email": "string",
|
||||
"password": "string"
|
||||
}
|
||||
|
||||
出参: // 同注册出参
|
||||
```
|
||||
|
||||
## 2. 邮件管理模块
|
||||
|
||||
### 2.1 创建未来邮件
|
||||
```typescript
|
||||
POST /api/v1/mails
|
||||
入参:
|
||||
{
|
||||
"title": "string",
|
||||
"content": "string",
|
||||
"recipientType": "SELF" | "SPECIFIC" | "PUBLIC", // 收件人类型
|
||||
"recipientEmail": "string?", // 指定收件人邮箱(当recipientType为SPECIFIC时必填)
|
||||
"sendTime": "string", // ISO时间格式 "2025-12-31T23:59:59Z"
|
||||
"triggerType": "TIME" | "LOCATION" | "EVENT",
|
||||
"triggerCondition": {
|
||||
"location": {
|
||||
"latitude": "number?",
|
||||
"longitude": "number?",
|
||||
"city": "string?"
|
||||
},
|
||||
"event": {
|
||||
"keywords": "string[]?",
|
||||
"type": "string?"
|
||||
}
|
||||
},
|
||||
"attachments": [
|
||||
{
|
||||
"type": "IMAGE" | "VOICE" | "VIDEO",
|
||||
"url": "string",
|
||||
"thumbnail": "string?"
|
||||
}
|
||||
],
|
||||
"isEncrypted": "boolean",
|
||||
"capsuleStyle": "string" // 胶囊皮肤
|
||||
}
|
||||
|
||||
出参:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"mailId": "string",
|
||||
"capsuleId": "string",
|
||||
"status": "DRAFT" | "PENDING" | "DELIVERING" | "DELIVERED",
|
||||
"createdAt": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 获取邮件列表
|
||||
```typescript
|
||||
GET /api/v1/mails
|
||||
查询参数:
|
||||
{
|
||||
"type": "INBOX" | "SENT" | "DRAFT", // 邮件类型
|
||||
"status": "PENDING" | "DELIVERING" | "DELIVERED", // 状态筛选
|
||||
"page": "number",
|
||||
"size": "number"
|
||||
}
|
||||
|
||||
出参:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"mailId": "string",
|
||||
"title": "string",
|
||||
"sender": {
|
||||
"userId": "string",
|
||||
"username": "string",
|
||||
"avatar": "string"
|
||||
},
|
||||
"recipient": {
|
||||
"userId": "string",
|
||||
"username": "string",
|
||||
"avatar": "string"
|
||||
},
|
||||
"sendTime": "string",
|
||||
"deliveryTime": "string?",
|
||||
"status": "string",
|
||||
"hasAttachments": "boolean",
|
||||
"isEncrypted": "boolean",
|
||||
"capsuleStyle": "string",
|
||||
"countdown": "number?" // 倒计时秒数(仅status=PENDING时返回)
|
||||
}
|
||||
],
|
||||
"total": "number",
|
||||
"page": "number",
|
||||
"size": "number"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 获取邮件详情
|
||||
```typescript
|
||||
GET /api/v1/mails/{mailId}
|
||||
出参:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"mailId": "string",
|
||||
"title": "string",
|
||||
"content": "string",
|
||||
"sender": {
|
||||
"userId": "string",
|
||||
"username": "string",
|
||||
"avatar": "string",
|
||||
"email": "string"
|
||||
},
|
||||
"recipient": {
|
||||
"userId": "string",
|
||||
"username": "string",
|
||||
"avatar": "string",
|
||||
"email": "string"
|
||||
},
|
||||
"sendTime": "string",
|
||||
"createdAt": "string",
|
||||
"deliveryTime": "string?",
|
||||
"status": "string",
|
||||
"triggerType": "string",
|
||||
"triggerCondition": "object",
|
||||
"attachments": [
|
||||
{
|
||||
"id": "string",
|
||||
"type": "string",
|
||||
"url": "string",
|
||||
"thumbnail": "string?",
|
||||
"size": "number"
|
||||
}
|
||||
],
|
||||
"isEncrypted": "boolean",
|
||||
"capsuleStyle": "string",
|
||||
"canEdit": "boolean", // 是否可编辑(仅草稿状态)
|
||||
"canRevoke": "boolean" // 是否可撤销(仅待投递状态)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 更新邮件(投递前)
|
||||
```typescript
|
||||
PUT /api/v1/mails/{mailId}
|
||||
入参: // 同创建邮件,但所有字段可选
|
||||
出参: // 同创建邮件出参
|
||||
```
|
||||
|
||||
### 2.5 撤销发送
|
||||
```typescript
|
||||
POST /api/v1/mails/{mailId}/revoke
|
||||
出参:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"mailId": "string",
|
||||
"status": "REVOKED"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 时光胶囊模块
|
||||
|
||||
### 3.1 获取时光胶囊视图
|
||||
```typescript
|
||||
GET /api/v1/capsules
|
||||
出参:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"capsules": [
|
||||
{
|
||||
"capsuleId": "string",
|
||||
"mailId": "string",
|
||||
"title": "string",
|
||||
"sendTime": "string",
|
||||
"deliveryTime": "string",
|
||||
"progress": "number", // 0-1 的进度
|
||||
"position": {
|
||||
"x": "number", // 0-1 相对位置
|
||||
"y": "number",
|
||||
"z": "number"
|
||||
},
|
||||
"style": "string",
|
||||
"glowIntensity": "number" // 发光强度
|
||||
}
|
||||
],
|
||||
"scene": "SPACE" | "OCEAN", // 场景类型
|
||||
"background": "string" // 背景配置
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. AI助手模块
|
||||
|
||||
### 4.1 AI写作辅助
|
||||
```typescript
|
||||
POST /api/v1/ai/writing-assistant
|
||||
入参:
|
||||
{
|
||||
"prompt": "string", // 用户输入
|
||||
"type": "OUTLINE" | "DRAFT" | "COMPLETE", // 辅助类型
|
||||
"tone": "FORMAL" | "CASUAL" | "EMOTIONAL" | "INSPIRATIONAL", // 语气
|
||||
"length": "SHORT" | "MEDIUM" | "LONG", // 长度
|
||||
"context": "string?" // 上下文信息
|
||||
}
|
||||
|
||||
出参:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"content": "string",
|
||||
"suggestions": "string[]",
|
||||
"estimatedTime": "number" // 预计写作时间(分钟)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 情感分析
|
||||
```typescript
|
||||
POST /api/v1/ai/sentiment-analysis
|
||||
入参:
|
||||
{
|
||||
"content": "string"
|
||||
}
|
||||
|
||||
出参:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"sentiment": "POSITIVE" | "NEUTRAL" | "NEGATIVE" | "MIXED",
|
||||
"confidence": "number", // 0-1 置信度
|
||||
"emotions": [
|
||||
{
|
||||
"type": "HAPPY" | "SAD" | "HOPEFUL" | "NOSTALGIC" | "EXCITED",
|
||||
"score": "number"
|
||||
}
|
||||
],
|
||||
"keywords": "string[]",
|
||||
"summary": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 个人空间模块
|
||||
|
||||
### 5.1 获取时间线
|
||||
```typescript
|
||||
GET /api/v1/timeline
|
||||
查询参数:
|
||||
{
|
||||
"startDate": "string?",
|
||||
"endDate": "string?",
|
||||
"type": "ALL" | "SENT" | "RECEIVED"
|
||||
}
|
||||
|
||||
出参:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"timeline": [
|
||||
{
|
||||
"date": "string",
|
||||
"events": [
|
||||
{
|
||||
"type": "SENT" | "RECEIVED",
|
||||
"mailId": "string",
|
||||
"title": "string",
|
||||
"time": "string",
|
||||
"withUser": {
|
||||
"userId": "string",
|
||||
"username": "string",
|
||||
"avatar": "string"
|
||||
},
|
||||
"emotion": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 获取统计数据
|
||||
```typescript
|
||||
GET /api/v1/statistics
|
||||
出参:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"totalSent": "number",
|
||||
"totalReceived": "number",
|
||||
"timeTravelDuration": "number", // 总时间旅行时长(天)
|
||||
"mostFrequentRecipient": "string",
|
||||
"mostCommonYear": "number",
|
||||
"keywordCloud": [
|
||||
{
|
||||
"word": "string",
|
||||
"count": "number",
|
||||
"size": "number"
|
||||
}
|
||||
],
|
||||
"monthlyStats": [
|
||||
{
|
||||
"month": "string",
|
||||
"sent": "number",
|
||||
"received": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 系统管理模块
|
||||
|
||||
### 6.1 获取用户订阅信息
|
||||
```typescript
|
||||
GET /api/v1/user/subscription
|
||||
出参:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"plan": "FREE" | "PREMIUM",
|
||||
"remainingMails": "number",
|
||||
"maxAttachmentSize": "number",
|
||||
"features": {
|
||||
"advancedTriggers": "boolean",
|
||||
"customCapsules": "boolean",
|
||||
"aiAssistant": "boolean"
|
||||
},
|
||||
"expireDate": "string?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 需要的核心接口列表
|
||||
|
||||
1. **认证相关**
|
||||
- 用户注册 `/api/v1/auth/register`
|
||||
- 用户登录 `/api/v1/auth/login`
|
||||
- 刷新token `/api/v1/auth/refresh`
|
||||
- 退出登录 `/api/v1/auth/logout`
|
||||
|
||||
2. **邮件管理**
|
||||
- 创建邮件 `/api/v1/mails`
|
||||
- 获取邮件列表 `/api/v1/mails`
|
||||
- 获取邮件详情 `/api/v1/mails/{mailId}`
|
||||
- 更新邮件 `/api/v1/mails/{mailId}`
|
||||
- 删除邮件 `/api/v1/mails/{mailId}`
|
||||
- 撤销发送 `/api/v1/mails/{mailId}/revoke`
|
||||
|
||||
3. **时光胶囊**
|
||||
- 获取胶囊视图 `/api/v1/capsules`
|
||||
- 更新胶囊样式 `/api/v1/capsules/{capsuleId}/style`
|
||||
|
||||
4. **AI助手**
|
||||
- 写作辅助 `/api/v1/ai/writing-assistant`
|
||||
- 情感分析 `/api/v1/ai/sentiment-analysis`
|
||||
- 未来预测 `/api/v1/ai/future-prediction`
|
||||
|
||||
5. **个人空间**
|
||||
- 时间线 `/api/v1/timeline`
|
||||
- 统计数据 `/api/v1/statistics`
|
||||
- 用户信息 `/api/v1/user/profile`
|
||||
|
||||
6. **文件上传**
|
||||
- 上传附件 `/api/v1/upload/attachment`
|
||||
- 上传头像 `/api/v1/upload/avatar`
|
||||
|
||||
7. **推送通知**
|
||||
- 注册设备 `/api/v1/notification/device`
|
||||
- 获取通知设置 `/api/v1/notification/settings`
|
||||
|
||||
这些接口设计考虑了产品的核心功能,包括邮件的创建、管理、投递,以及增强用户体验的AI功能和可视化功能。接口设计遵循RESTful原则,并考虑了扩展性和安全性。
|
||||
421
README-API.md
Normal file
421
README-API.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# ChronoMail 前端API对接文档
|
||||
|
||||
## 项目概述
|
||||
|
||||
ChronoMail(未来邮箱)是一个基于Vue 3 + Electron + Vant + Axios构建的跨平台邮件应用,支持发送未来邮件、时光胶囊、AI辅助写作等功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端框架**: Vue 3
|
||||
- **桌面端**: Electron
|
||||
- **路由**: Vue Router 4
|
||||
- **UI组件库**: Vant 4
|
||||
- **HTTP客户端**: Axios
|
||||
- **状态管理**: Vue 3 Composition API
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API接口模块
|
||||
│ ├── index.js # API方法集合
|
||||
│ └── request.js # Axios实例和拦截器配置
|
||||
├── assets/ # 静态资源
|
||||
│ └── styles/ # 样式文件
|
||||
│ └── global.css # 全局样式
|
||||
├── 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 # 胶囊详情页
|
||||
├── App.vue # 根组件
|
||||
└── main.js # 入口文件
|
||||
```
|
||||
|
||||
## API接口说明
|
||||
|
||||
### 1. 认证相关接口
|
||||
|
||||
#### 用户注册
|
||||
```javascript
|
||||
import { authAPI } from '@/api'
|
||||
|
||||
const registerData = {
|
||||
username: 'string', // 用户名
|
||||
email: 'string', // 邮箱
|
||||
password: 'string', // 密码
|
||||
avatar: 'string?' // 头像URL(可选)
|
||||
}
|
||||
|
||||
const response = await authAPI.register(registerData)
|
||||
// 响应数据: { code: 200, message: 'success', data: { userId, username, email, avatar, token, refreshToken } }
|
||||
```
|
||||
|
||||
#### 用户登录
|
||||
```javascript
|
||||
const loginData = {
|
||||
email: 'string', // 邮箱
|
||||
password: 'string' // 密码
|
||||
}
|
||||
|
||||
const response = await authAPI.login(loginData)
|
||||
// 响应数据同注册
|
||||
```
|
||||
|
||||
#### 刷新Token
|
||||
```javascript
|
||||
const response = await authAPI.refreshToken(refreshToken)
|
||||
// 响应数据: { code: 200, message: 'success', data: { token, refreshToken } }
|
||||
```
|
||||
|
||||
#### 退出登录
|
||||
```javascript
|
||||
const response = await authAPI.logout()
|
||||
// 响应数据: { code: 200, message: 'success' }
|
||||
```
|
||||
|
||||
### 2. 邮件管理接口
|
||||
|
||||
#### 创建邮件
|
||||
```javascript
|
||||
import { mailAPI } from '@/api'
|
||||
|
||||
const mailData = {
|
||||
title: 'string', // 邮件标题
|
||||
content: 'string', // 邮件内容
|
||||
recipientType: 'SELF|SPECIFIC|PUBLIC', // 收件人类型
|
||||
recipientEmail: 'string?', // 指定收件人邮箱(当recipientType为SPECIFIC时必填)
|
||||
sendTime: 'string', // ISO时间格式 "2025-12-31T23:59:59Z"
|
||||
triggerType: 'TIME|LOCATION|EVENT', // 触发类型
|
||||
triggerCondition: { // 触发条件
|
||||
location: {
|
||||
latitude: 'number?',
|
||||
longitude: 'number?',
|
||||
city: 'string?'
|
||||
},
|
||||
event: {
|
||||
keywords: 'string[]?',
|
||||
type: 'string?'
|
||||
}
|
||||
},
|
||||
attachments: [ // 附件列表
|
||||
{
|
||||
type: 'IMAGE|VOICE|VIDEO',
|
||||
url: 'string',
|
||||
thumbnail: 'string?'
|
||||
}
|
||||
],
|
||||
isEncrypted: 'boolean', // 是否加密
|
||||
capsuleStyle: 'string' // 胶囊皮肤
|
||||
}
|
||||
|
||||
const response = await mailAPI.createMail(mailData)
|
||||
// 响应数据: { code: 200, message: 'success', data: { mailId, capsuleId, status, createdAt } }
|
||||
```
|
||||
|
||||
#### 获取邮件列表
|
||||
```javascript
|
||||
const params = {
|
||||
type: 'INBOX|SENT|DRAFT', // 邮件类型
|
||||
status: 'PENDING|DELIVERING|DELIVERED', // 状态筛选
|
||||
page: 1, // 页码
|
||||
size: 10 // 每页数量
|
||||
}
|
||||
|
||||
const response = await mailAPI.getMails(params)
|
||||
// 响应数据: { code: 200, message: 'success', data: { list, total, page, size } }
|
||||
```
|
||||
|
||||
#### 获取邮件详情
|
||||
```javascript
|
||||
const mailId = 'string'
|
||||
const response = await mailAPI.getMailDetail(mailId)
|
||||
// 响应数据: { code: 200, message: 'success', data: { 邮件详情 } }
|
||||
```
|
||||
|
||||
#### 更新邮件
|
||||
```javascript
|
||||
const mailId = 'string'
|
||||
const updateData = { /* 同创建邮件,但所有字段可选 */ }
|
||||
const response = await mailAPI.updateMail(mailId, updateData)
|
||||
// 响应数据: { code: 200, message: 'success', data: { 更新后的邮件信息 } }
|
||||
```
|
||||
|
||||
#### 删除邮件
|
||||
```javascript
|
||||
const mailId = 'string'
|
||||
const response = await mailAPI.deleteMail(mailId)
|
||||
// 响应数据: { code: 200, message: 'success' }
|
||||
```
|
||||
|
||||
#### 撤销发送
|
||||
```javascript
|
||||
const mailId = 'string'
|
||||
const response = await mailAPI.revokeMail(mailId)
|
||||
// 响应数据: { code: 200, message: 'success', data: { mailId, status: 'REVOKED' } }
|
||||
```
|
||||
|
||||
### 3. 时光胶囊接口
|
||||
|
||||
#### 获取胶囊视图
|
||||
```javascript
|
||||
import { capsuleAPI } from '@/api'
|
||||
|
||||
const response = await capsuleAPI.getCapsules()
|
||||
// 响应数据: { code: 200, message: 'success', data: { capsules, scene, background } }
|
||||
```
|
||||
|
||||
#### 更新胶囊样式
|
||||
```javascript
|
||||
const capsuleId = 'string'
|
||||
const style = 'string'
|
||||
const response = await capsuleAPI.updateCapsuleStyle(capsuleId, style)
|
||||
// 响应数据: { code: 200, message: 'success' }
|
||||
```
|
||||
|
||||
### 4. AI助手接口
|
||||
|
||||
#### 写作辅助
|
||||
```javascript
|
||||
import { aiAPI } from '@/api'
|
||||
|
||||
const data = {
|
||||
prompt: 'string', // 用户输入
|
||||
type: 'OUTLINE|DRAFT|COMPLETE', // 辅助类型
|
||||
tone: 'FORMAL|CASUAL|EMOTIONAL|INSPIRATIONAL', // 语气
|
||||
length: 'SHORT|MEDIUM|LONG', // 长度
|
||||
context: 'string?' // 上下文信息
|
||||
}
|
||||
|
||||
const response = await aiAPI.writingAssistant(data)
|
||||
// 响应数据: { code: 200, message: 'success', data: { content, suggestions, estimatedTime } }
|
||||
```
|
||||
|
||||
#### 情感分析
|
||||
```javascript
|
||||
const content = 'string'
|
||||
const response = await aiAPI.sentimentAnalysis(content)
|
||||
// 响应数据: { code: 200, message: 'success', data: { sentiment, confidence, emotions, keywords, summary } }
|
||||
```
|
||||
|
||||
#### 未来预测
|
||||
```javascript
|
||||
const data = { /* 预测参数 */ }
|
||||
const response = await aiAPI.futurePrediction(data)
|
||||
// 响应数据: { code: 200, message: 'success', data: { 预测结果 } }
|
||||
```
|
||||
|
||||
### 5. 个人空间接口
|
||||
|
||||
#### 获取时间线
|
||||
```javascript
|
||||
import { userAPI } from '@/api'
|
||||
|
||||
const params = {
|
||||
startDate: 'string?', // 开始日期
|
||||
endDate: 'string?', // 结束日期
|
||||
type: 'ALL|SENT|RECEIVED' // 类型
|
||||
}
|
||||
|
||||
const response = await userAPI.getTimeline(params)
|
||||
// 响应数据: { code: 200, message: 'success', data: { timeline } }
|
||||
```
|
||||
|
||||
#### 获取统计数据
|
||||
```javascript
|
||||
const response = await userAPI.getStatistics()
|
||||
// 响应数据: { code: 200, message: 'success', data: { 统计数据 } }
|
||||
```
|
||||
|
||||
#### 获取用户信息
|
||||
```javascript
|
||||
const response = await userAPI.getUserProfile()
|
||||
// 响应数据: { code: 200, message: 'success', data: { 用户信息 } }
|
||||
```
|
||||
|
||||
#### 更新用户信息
|
||||
```javascript
|
||||
const data = { /* 用户信息 */ }
|
||||
const response = await userAPI.updateUserProfile(data)
|
||||
// 响应数据: { code: 200, message: 'success', data: { 更新后的用户信息 } }
|
||||
```
|
||||
|
||||
#### 获取用户订阅信息
|
||||
```javascript
|
||||
const response = await userAPI.getSubscription()
|
||||
// 响应数据: { code: 200, message: 'success', data: { 订阅信息 } }
|
||||
```
|
||||
|
||||
### 6. 文件上传接口
|
||||
|
||||
#### 上传附件
|
||||
```javascript
|
||||
import { uploadAPI } from '@/api'
|
||||
|
||||
const file = /* File对象 */
|
||||
const response = await uploadAPI.uploadAttachment(file)
|
||||
// 响应数据: { code: 200, message: 'success', data: { 文件信息 } }
|
||||
```
|
||||
|
||||
#### 上传头像
|
||||
```javascript
|
||||
const file = /* File对象 */
|
||||
const response = await uploadAPI.uploadAvatar(file)
|
||||
// 响应数据: { code: 200, message: 'success', data: { 头像URL } }
|
||||
```
|
||||
|
||||
### 7. 推送通知接口
|
||||
|
||||
#### 注册设备
|
||||
```javascript
|
||||
import { notificationAPI } from '@/api'
|
||||
|
||||
const data = { /* 设备信息 */ }
|
||||
const response = await notificationAPI.registerDevice(data)
|
||||
// 响应数据: { code: 200, message: 'success' }
|
||||
```
|
||||
|
||||
#### 获取通知设置
|
||||
```javascript
|
||||
const response = await notificationAPI.getNotificationSettings()
|
||||
// 响应数据: { code: 200, message: 'success', data: { 通知设置 } }
|
||||
```
|
||||
|
||||
#### 更新通知设置
|
||||
```javascript
|
||||
const data = { /* 通知设置 */ }
|
||||
const response = await notificationAPI.updateNotificationSettings(data)
|
||||
// 响应数据: { code: 200, message: 'success' }
|
||||
```
|
||||
|
||||
## 状态管理
|
||||
|
||||
### 用户状态
|
||||
```javascript
|
||||
import { userState, userActions } from '@/store'
|
||||
|
||||
// 获取用户状态
|
||||
console.log(userState.isLoggedIn)
|
||||
console.log(userState.userInfo)
|
||||
|
||||
// 用户操作
|
||||
await userActions.login(credentials)
|
||||
await userActions.logout()
|
||||
await userActions.getSubscription()
|
||||
```
|
||||
|
||||
### 邮件状态
|
||||
```javascript
|
||||
import { mailState, mailActions } from '@/store'
|
||||
|
||||
// 获取邮件状态
|
||||
console.log(mailState.inboxList)
|
||||
console.log(mailState.currentMail)
|
||||
|
||||
// 邮件操作
|
||||
await mailActions.getMails('INBOX')
|
||||
await mailActions.getMailDetail(mailId)
|
||||
await mailActions.createMail(mailData)
|
||||
```
|
||||
|
||||
### 胶囊状态
|
||||
```javascript
|
||||
import { capsuleState, capsuleActions } from '@/store'
|
||||
|
||||
// 获取胶囊状态
|
||||
console.log(capsuleState.capsules)
|
||||
|
||||
// 胶囊操作
|
||||
await capsuleActions.getCapsules()
|
||||
await capsuleActions.updateCapsuleStyle(capsuleId, style)
|
||||
```
|
||||
|
||||
## 工具函数
|
||||
|
||||
```javascript
|
||||
import { formatDate, countdown, validateEmail, validatePassword } from '@/utils'
|
||||
|
||||
// 格式化日期
|
||||
const formattedDate = formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
// 计算倒计时
|
||||
const { days, hours, minutes, seconds } = countdown('2025-12-31T23:59:59Z')
|
||||
|
||||
// 验证邮箱
|
||||
const isValidEmail = validateEmail('user@example.com')
|
||||
|
||||
// 验证密码强度
|
||||
const { strength, message } = validatePassword('password123')
|
||||
```
|
||||
|
||||
## 环境配置
|
||||
|
||||
项目使用环境变量来配置不同环境的API地址:
|
||||
|
||||
- 开发环境:`.env.development`
|
||||
- 生产环境:`.env.production`
|
||||
|
||||
```bash
|
||||
# 开发环境配置
|
||||
VUE_APP_API_BASE_URL=http://localhost:3000/api/v1
|
||||
VUE_APP_TITLE=ChronoMail - 未来邮箱
|
||||
|
||||
# 生产环境配置
|
||||
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
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 所有API请求都会自动添加Authorization头,格式为`Bearer {token}`
|
||||
2. 当收到401或403响应时,会自动清除用户信息并跳转到登录页
|
||||
3. 文件上传需要使用FormData格式
|
||||
4. 日期格式统一使用ISO 8601标准
|
||||
5. 所有状态变更都应通过相应的Actions方法进行,以保证状态一致性
|
||||
|
||||
## 错误处理
|
||||
|
||||
API请求失败时,会显示错误提示并返回Promise.reject,可以在组件中使用try-catch处理:
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const response = await mailAPI.createMail(mailData)
|
||||
// 处理成功响应
|
||||
} catch (error) {
|
||||
// 处理错误
|
||||
console.error('创建邮件失败:', error)
|
||||
}
|
||||
```
|
||||
155
README.md
Normal file
155
README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# ChronoMail - 时光胶囊邮箱
|
||||
|
||||
## 项目简介
|
||||
ChronoMail是一个基于Vue3的时光胶囊邮箱应用,用户可以撰写邮件并设定未来的发送时间,将此刻的心情、想法和祝福寄给未来的自己或他人。
|
||||
|
||||
## 技术栈
|
||||
- Vue 3
|
||||
- Vue Router
|
||||
- Vant UI组件库
|
||||
- Axios
|
||||
- Electron
|
||||
|
||||
## 功能特点
|
||||
- 📧 撰写时光胶囊邮件
|
||||
- ⏰ 设定未来发送时间
|
||||
- 🌌 深空主题UI设计
|
||||
- 📱 移动端适配
|
||||
- 🔐 用户登录注册
|
||||
- 📬 收件箱和发件箱管理
|
||||
- 🕐 时间线展示
|
||||
- 🤖 AI写作辅助
|
||||
- 📊 数据统计
|
||||
- 🎨 自定义胶囊样式
|
||||
|
||||
## API接口对接
|
||||
|
||||
本项目已完整对接后端API,支持以下功能:
|
||||
|
||||
### 用户认证
|
||||
- 用户注册与登录
|
||||
- Token刷新机制
|
||||
- 自动登录状态保持
|
||||
|
||||
### 邮件管理
|
||||
- 创建未来邮件
|
||||
- 获取邮件列表(收件箱/发件箱/草稿箱)
|
||||
- 邮件详情查看
|
||||
- 邮件编辑与删除
|
||||
- 邮件撤销发送
|
||||
|
||||
### 时光胶囊
|
||||
- 胶囊视图展示
|
||||
- 胶囊样式自定义
|
||||
- 3D空间效果
|
||||
|
||||
### AI助手
|
||||
- 写作辅助
|
||||
- 情感分析
|
||||
- 未来预测
|
||||
|
||||
### 个人空间
|
||||
- 时间线展示
|
||||
- 数据统计
|
||||
- 用户信息管理
|
||||
|
||||
### 文件上传
|
||||
- 附件上传
|
||||
- 头像上传
|
||||
|
||||
### 推送通知
|
||||
- 设备注册
|
||||
- 通知设置
|
||||
|
||||
详细的API文档请参考:[README-API.md](./README-API.md)
|
||||
|
||||
## 安装和运行
|
||||
|
||||
### 安装依赖
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
```bash
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 运行Electron应用
|
||||
```bash
|
||||
npm run electron:serve
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
```
|
||||
src/
|
||||
├── api/ # API接口模块
|
||||
│ ├── index.js # API方法集合
|
||||
│ └── request.js # Axios实例和拦截器配置
|
||||
├── assets/ # 静态资源
|
||||
│ └── styles/ # 样式文件
|
||||
├── components/ # 公共组件
|
||||
├── router/ # 路由配置
|
||||
├── store/ # 状态管理
|
||||
├── utils/ # 工具函数
|
||||
├── 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 # 入口文件
|
||||
```
|
||||
|
||||
## 开发说明
|
||||
1. 本项目使用Vue 3 Composition API
|
||||
2. 使用Vant UI组件库进行界面开发
|
||||
3. 使用Vue Router进行路由管理
|
||||
4. 使用Axios进行HTTP请求
|
||||
5. 使用Vue 3响应式API进行状态管理
|
||||
6. 项目采用深空主题设计,营造时光穿梭的视觉体验
|
||||
|
||||
## 环境配置
|
||||
|
||||
项目使用环境变量来配置不同环境的API地址:
|
||||
|
||||
- 开发环境:`.env.development`
|
||||
- 生产环境:`.env.production`
|
||||
|
||||
```bash
|
||||
# 开发环境配置
|
||||
VUE_APP_API_BASE_URL=http://localhost:3000/api/v1
|
||||
VUE_APP_TITLE=ChronoMail - 未来邮箱
|
||||
|
||||
# 生产环境配置
|
||||
VUE_APP_API_BASE_URL=https://api.chronomail.com/api/v1
|
||||
VUE_APP_TITLE=ChronoMail - 未来邮箱
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
- 所有API请求都会自动添加Authorization头,格式为`Bearer {token}`
|
||||
- 当收到401或403响应时,会自动清除用户信息并跳转到登录页
|
||||
- 文件上传需要使用FormData格式
|
||||
- 日期格式统一使用ISO 8601标准
|
||||
- 所有状态变更都应通过相应的Actions方法进行,以保证状态一致性
|
||||
|
||||
## API示例页面
|
||||
|
||||
项目包含一个API示例页面(`/api-demo`),展示了如何在Vue组件中使用各种API接口,包括:
|
||||
- 用户认证
|
||||
- 邮件操作
|
||||
- 胶囊管理
|
||||
- AI助手调用
|
||||
|
||||
您可以在登录后访问该页面,查看API调用的实际效果。
|
||||
5
babel.config.js
Normal file
5
babel.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
86
background.js
Normal file
86
background.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const { app, BrowserWindow, Menu } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
function createWindow () {
|
||||
// 创建浏览器窗口
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
enableRemoteModule: true
|
||||
},
|
||||
icon: path.join(__dirname, 'assets/icon.png'),
|
||||
show: false,
|
||||
titleBarStyle: 'hiddenInset'
|
||||
})
|
||||
|
||||
// 加载应用的 index.html
|
||||
mainWindow.loadFile('dist/index.html')
|
||||
|
||||
// 窗口准备好后显示
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
// 打开开发者工具
|
||||
// mainWindow.webContents.openDevTools()
|
||||
|
||||
// 创建菜单
|
||||
const template = [
|
||||
{
|
||||
label: 'ChronoMail',
|
||||
submenu: [
|
||||
{
|
||||
label: '关于 ChronoMail',
|
||||
role: 'about'
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
|
||||
click: () => {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '编辑',
|
||||
submenu: [
|
||||
{ label: '撤销', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
|
||||
{ label: '重做', accelerator: 'Shift+CmdOrCtrl+Z', role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ label: '剪切', accelerator: 'CmdOrCtrl+X', role: 'cut' },
|
||||
{ label: '复制', accelerator: 'CmdOrCtrl+C', role: 'copy' },
|
||||
{ label: '粘贴', accelerator: 'CmdOrCtrl+V', role: 'paste' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const menu = Menu.buildFromTemplate(template)
|
||||
Menu.setApplicationMenu(menu)
|
||||
}
|
||||
|
||||
// 这段程序将会在 Electron 结束初始化和创建浏览器窗口的时候调用
|
||||
// 部分 API 在 ready 事件触发后才能使用。
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
// 在 macOS 上,当单击 dock 图标并且没有其他窗口打开时,
|
||||
// 通常在应用程序中重新创建一个窗口。
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
// 当全部窗口关闭时退出程序
|
||||
app.on('window-all-closed', function () {
|
||||
// 在 macOS 上,除非用户用 Cmd + Q 确定地退出,
|
||||
// 否则绝大部分应用及其菜单栏会保持激活。
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
// 在这个文件中,你可以续写应用剩下主进程代码。
|
||||
// 也可以拆分成几个文件,然后用 require 导入。
|
||||
12294
package-lock.json
generated
Normal file
12294
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "future-mail",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vant/touch-emulator": "^1.4.0",
|
||||
"axios": "^1.6.2",
|
||||
"core-js": "^3.8.3",
|
||||
"vant": "^4.8.11",
|
||||
"vue": "^3.2.13",
|
||||
"vue-router": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.16",
|
||||
"@babel/eslint-parser": "^7.12.16",
|
||||
"@vue/cli-plugin-babel": "~5.0.0",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-plugin-router": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-vue": "^8.0.3"
|
||||
}
|
||||
}
|
||||
11
public/favicon.svg
Normal file
11
public/favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="url(#gradient)"/>
|
||||
<path d="M16 8C16 8 10 12 10 16C10 20 14 24 16 24C18 24 22 20 22 16C22 12 16 8 16 8Z" fill="white" fill-opacity="0.9"/>
|
||||
<circle cx="16" cy="16" r="3" fill="#0F1C2E"/>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0F1C2E"/>
|
||||
<stop offset="1" stop-color="#1D3B5A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 553 B |
80
public/index.html
Normal file
80
public/index.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.svg" type="image/svg+xml">
|
||||
<title>ChronoMail - 时光胶囊邮箱</title>
|
||||
<style>
|
||||
/* 加载动画 */
|
||||
.loading-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #0F1C2E, #1D3B5A);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
width: 80px;
|
||||
height: 120px;
|
||||
background: linear-gradient(135deg, #1D3B5A, #0F1C2E);
|
||||
border-radius: 40px;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 30px rgba(0, 212, 255, 0.3);
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-logo::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: radial-gradient(circle, #00D4FF, transparent);
|
||||
border-radius: 50%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 30px;
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>很抱歉,ChronoMail需要启用JavaScript才能正常工作。请启用它以继续。</strong>
|
||||
</noscript>
|
||||
<div id="app">
|
||||
<!-- 加载动画 -->
|
||||
<div class="loading-container">
|
||||
<div class="loading-logo"></div>
|
||||
<div class="loading-text">ChronoMail</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
31
src/App.vue
Normal file
31
src/App.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App',
|
||||
mounted() {
|
||||
// 隐藏加载动画
|
||||
setTimeout(() => {
|
||||
const loadingContainer = document.querySelector('.loading-container');
|
||||
if (loadingContainer) {
|
||||
loadingContainer.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
loadingContainer.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
172
src/api/index.js
Normal file
172
src/api/index.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import api from './request'
|
||||
|
||||
// 用户认证相关API
|
||||
export const authAPI = {
|
||||
// 用户注册
|
||||
register(data) {
|
||||
return api.post('/auth/register', data)
|
||||
},
|
||||
|
||||
// 用户登录
|
||||
login(data) {
|
||||
return api.post('/auth/login', data)
|
||||
},
|
||||
|
||||
// 刷新token
|
||||
refreshToken(refreshToken) {
|
||||
return api.post('/auth/refresh', { refreshToken })
|
||||
},
|
||||
|
||||
// 退出登录
|
||||
logout() {
|
||||
return api.post('/auth/logout')
|
||||
}
|
||||
}
|
||||
|
||||
// 邮件管理相关API
|
||||
export const mailAPI = {
|
||||
// 创建邮件
|
||||
createMail(data) {
|
||||
return api.post('/mails', data)
|
||||
},
|
||||
|
||||
// 获取邮件列表
|
||||
getMails(params) {
|
||||
return api.get('/mails', { params })
|
||||
},
|
||||
|
||||
// 获取邮件详情
|
||||
getMailDetail(mailId) {
|
||||
return api.get(`/mails/${mailId}`)
|
||||
},
|
||||
|
||||
// 更新邮件
|
||||
updateMail(mailId, data) {
|
||||
return api.put(`/mails/${mailId}`, data)
|
||||
},
|
||||
|
||||
// 删除邮件
|
||||
deleteMail(mailId) {
|
||||
return api.delete(`/mails/${mailId}`)
|
||||
},
|
||||
|
||||
// 撤销发送
|
||||
revokeMail(mailId) {
|
||||
return api.post(`/mails/${mailId}/revoke`)
|
||||
}
|
||||
}
|
||||
|
||||
// 时光胶囊相关API
|
||||
export const capsuleAPI = {
|
||||
// 获取胶囊视图
|
||||
getCapsules() {
|
||||
return api.get('/capsules')
|
||||
},
|
||||
|
||||
// 更新胶囊样式
|
||||
updateCapsuleStyle(capsuleId, style) {
|
||||
return api.put(`/capsules/${capsuleId}/style`, { style })
|
||||
}
|
||||
}
|
||||
|
||||
// AI助手相关API
|
||||
export const aiAPI = {
|
||||
// 写作辅助
|
||||
writingAssistant(data) {
|
||||
return api.post('/ai/writing-assistant', data)
|
||||
},
|
||||
|
||||
// 情感分析
|
||||
sentimentAnalysis(content) {
|
||||
return api.post('/ai/sentiment-analysis', { content })
|
||||
},
|
||||
|
||||
// 未来预测
|
||||
futurePrediction(data) {
|
||||
return api.post('/ai/future-prediction', data)
|
||||
}
|
||||
}
|
||||
|
||||
// 个人空间相关API
|
||||
export const userAPI = {
|
||||
// 获取时间线
|
||||
getTimeline(params) {
|
||||
return api.get('/timeline', { params })
|
||||
},
|
||||
|
||||
// 获取统计数据
|
||||
getStatistics() {
|
||||
return api.get('/statistics')
|
||||
},
|
||||
|
||||
// 获取用户信息
|
||||
getUserProfile() {
|
||||
return api.get('/user/profile')
|
||||
},
|
||||
|
||||
// 更新用户信息
|
||||
updateUserProfile(data) {
|
||||
return api.put('/user/profile', data)
|
||||
},
|
||||
|
||||
// 获取用户订阅信息
|
||||
getSubscription() {
|
||||
return api.get('/user/subscription')
|
||||
}
|
||||
}
|
||||
|
||||
// 文件上传相关API
|
||||
export const uploadAPI = {
|
||||
// 上传附件
|
||||
uploadAttachment(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return api.post('/upload/attachment', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 上传头像
|
||||
uploadAvatar(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return api.post('/upload/avatar', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 推送通知相关API
|
||||
export const notificationAPI = {
|
||||
// 注册设备
|
||||
registerDevice(data) {
|
||||
return api.post('/notification/device', data)
|
||||
},
|
||||
|
||||
// 获取通知设置
|
||||
getNotificationSettings() {
|
||||
return api.get('/notification/settings')
|
||||
},
|
||||
|
||||
// 更新通知设置
|
||||
updateNotificationSettings(data) {
|
||||
return api.put('/notification/settings', data)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出所有API
|
||||
export default {
|
||||
auth: authAPI,
|
||||
mail: mailAPI,
|
||||
capsule: capsuleAPI,
|
||||
ai: aiAPI,
|
||||
user: userAPI,
|
||||
upload: uploadAPI,
|
||||
notification: notificationAPI
|
||||
}
|
||||
89
src/api/request.js
Normal file
89
src/api/request.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import axios from 'axios'
|
||||
import { showFailToast } from 'vant'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: process.env.VUE_APP_API_BASE_URL || 'http://localhost:3000/api/v1',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
// 从localStorage获取token
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
const res = response.data
|
||||
|
||||
// 如果响应码不是200,则判断为错误
|
||||
if (res.code !== 200) {
|
||||
showFailToast(res.message || '请求失败')
|
||||
|
||||
// 401: 未登录或token过期
|
||||
// 403: 权限不足
|
||||
if (res.code === 401 || res.code === 403) {
|
||||
// 清除用户信息并跳转到登录页
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('userInfo')
|
||||
|
||||
// 如果不在登录页,则跳转到登录页
|
||||
if (window.location.hash !== '#/login') {
|
||||
window.location.href = '#/login'
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
} else {
|
||||
return res
|
||||
}
|
||||
},
|
||||
error => {
|
||||
console.error('请求错误:', error)
|
||||
|
||||
let message = '网络错误'
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 400:
|
||||
message = '请求参数错误'
|
||||
break
|
||||
case 401:
|
||||
message = '未授权,请登录'
|
||||
break
|
||||
case 403:
|
||||
message = '拒绝访问'
|
||||
break
|
||||
case 404:
|
||||
message = '请求地址不存在'
|
||||
break
|
||||
case 500:
|
||||
message = '服务器内部错误'
|
||||
break
|
||||
default:
|
||||
message = `连接错误${error.response.status}`
|
||||
}
|
||||
} else if (error.request) {
|
||||
message = '网络连接失败'
|
||||
}
|
||||
|
||||
showFailToast(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
450
src/assets/styles/global.css
Normal file
450
src/assets/styles/global.css
Normal file
@@ -0,0 +1,450 @@
|
||||
/* 全局样式 */
|
||||
:root {
|
||||
--primary-color: #0F1C2E;
|
||||
--secondary-color: #1D3B5A;
|
||||
--accent-color: #00D4FF;
|
||||
--gradient-color: linear-gradient(135deg, #1D3B5A, #0F1C2E);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0b3d0;
|
||||
--glass-bg: rgba(255, 255, 255, 0.1);
|
||||
--glass-border: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 深空背景 */
|
||||
.space-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--gradient-color);
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stars {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.star {
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
animation: twinkle 4s infinite;
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 玻璃拟态效果 */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 新拟态按钮 */
|
||||
.neumorphic-button {
|
||||
background: var(--gradient-color);
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
box-shadow: 8px 8px 16px rgba(0, 0, 0, 0.3),
|
||||
-8px -8px 16px rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.neumorphic-button:hover {
|
||||
box-shadow: 6px 6px 12px rgba(0, 0, 0, 0.3),
|
||||
-6px -6px 12px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.neumorphic-button:active {
|
||||
box-shadow: inset 4px 4px 8px rgba(0, 0, 0, 0.3),
|
||||
inset -4px -4px 8px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* 时间胶囊样式 */
|
||||
.time-capsule {
|
||||
width: 80px;
|
||||
height: 120px;
|
||||
background: var(--gradient-color);
|
||||
border-radius: 40px;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 30px rgba(0, 212, 255, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.time-capsule::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: radial-gradient(circle, var(--accent-color), transparent);
|
||||
border-radius: 50%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.time-capsule:hover {
|
||||
transform: translateY(-10px);
|
||||
box-shadow: 0 15px 40px rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面过渡动画 */
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.3s cubic-bezier(1.0, 0.5, 0.8, 1.0);
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--secondary-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 文本样式 */
|
||||
.text-primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 间距工具类 */
|
||||
.mt-10 { margin-top: 10px; }
|
||||
.mt-20 { margin-top: 20px; }
|
||||
.mt-30 { margin-top: 30px; }
|
||||
.mb-10 { margin-bottom: 10px; }
|
||||
.mb-20 { margin-bottom: 20px; }
|
||||
.mb-30 { margin-bottom: 30px; }
|
||||
.ml-10 { margin-left: 10px; }
|
||||
.mr-10 { margin-right: 10px; }
|
||||
.p-10 { padding: 10px; }
|
||||
.p-20 { padding: 20px; }
|
||||
|
||||
/* 布局类 */
|
||||
.flex { display: flex; }
|
||||
.flex-column { flex-direction: column; }
|
||||
.flex-center { display: flex; justify-content: center; align-items: center; }
|
||||
.flex-between { display: flex; justify-content: space-between; align-items: center; }
|
||||
.flex-1 { flex: 1; }
|
||||
|
||||
/* 文本对齐 */
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
/* 文本溢出处理 */
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-ellipsis-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 页面容器 */
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 60px; /* 为底部导航留出空间 */
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 列表项样式 */
|
||||
.list-item {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* 邮件状态标签 */
|
||||
.mail-status {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background-color: rgba(255, 193, 7, 0.2);
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.status-delivered {
|
||||
background-color: rgba(40, 167, 69, 0.2);
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.status-revoked {
|
||||
background-color: rgba(220, 53, 69, 0.2);
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
/* 倒计时样式 */
|
||||
.countdown {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 胶囊样式变体 */
|
||||
.capsule-style-1 {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.capsule-style-2 {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.capsule-style-3 {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.capsule-style-4 {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
|
||||
.capsule-style-5 {
|
||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||
}
|
||||
.p-30 { padding: 30px; }
|
||||
|
||||
/* 布局工具类 */
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.full-height {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 全局文本框样式 */
|
||||
input, textarea, .van-field__control {
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
border-radius: 12px !important;
|
||||
color: var(--text-primary) !important;
|
||||
padding: 16px !important;
|
||||
font-size: 16px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, .van-field__control:focus {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
border-color: var(--accent-color) !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.2), 0 4px 15px rgba(0, 0, 0, 0.15) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
input::placeholder, textarea::placeholder, .van-field__control::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
/* Vant字段组件样式优化 */
|
||||
.van-field {
|
||||
background: transparent !important;
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
|
||||
.van-field__label {
|
||||
color: var(--text-secondary) !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.van-field--error .van-field__control {
|
||||
border-color: #ff6b6b !important;
|
||||
}
|
||||
|
||||
.van-field--error .van-field__control:focus {
|
||||
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.2), 0 4px 15px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.van-field__error-message {
|
||||
color: #ff6b6b !important;
|
||||
}
|
||||
|
||||
/* 顶部导航栏样式 */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
margin: 15px;
|
||||
z-index: 10;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header .van-icon {
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.header .van-icon:hover {
|
||||
color: var(--accent-color);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 底部导航栏样式 */
|
||||
.custom-tabbar {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.custom-tabbar .van-tabbar-item {
|
||||
color: var(--text-secondary) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.custom-tabbar .van-tabbar-item--active {
|
||||
color: var(--accent-color) !important;
|
||||
}
|
||||
|
||||
.custom-tabbar .van-tabbar-item__icon {
|
||||
font-size: 20px !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.custom-tabbar .van-tabbar-item__text {
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
26
src/main.js
Normal file
26
src/main.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// 引入Vant组件库
|
||||
import Vant from 'vant'
|
||||
import 'vant/lib/index.css'
|
||||
|
||||
// 在桌面端使用Vant需要引入模拟触摸事件的库
|
||||
import '@vant/touch-emulator'
|
||||
|
||||
// 全局样式
|
||||
import './assets/styles/global.css'
|
||||
|
||||
// 引入状态管理
|
||||
import { userActions } from './store'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
app.use(Vant)
|
||||
|
||||
// 初始化用户状态
|
||||
userActions.initUserState()
|
||||
|
||||
app.mount('#app')
|
||||
120
src/router/index.js
Normal file
120
src/router/index.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/login'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: {
|
||||
title: '登录 - ChronoMail'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('../views/Register.vue'),
|
||||
meta: {
|
||||
title: '注册 - ChronoMail'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
name: 'Home',
|
||||
component: () => import('../views/Home.vue'),
|
||||
meta: {
|
||||
title: '时光胶囊 - ChronoMail',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/compose',
|
||||
name: 'Compose',
|
||||
component: () => import('../views/Compose.vue'),
|
||||
meta: {
|
||||
title: '撰写邮件 - ChronoMail',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/inbox',
|
||||
name: 'Inbox',
|
||||
component: () => import('../views/Inbox.vue'),
|
||||
meta: {
|
||||
title: '收件箱 - ChronoMail',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/sent',
|
||||
name: 'Sent',
|
||||
component: () => import('../views/Sent.vue'),
|
||||
meta: {
|
||||
title: '发件箱 - ChronoMail',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: () => import('../views/Profile.vue'),
|
||||
meta: {
|
||||
title: '个人中心 - ChronoMail',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/timeline',
|
||||
name: 'Timeline',
|
||||
component: () => import('../views/Timeline.vue'),
|
||||
meta: {
|
||||
title: '我的时间线 - ChronoMail',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/capsule/:id',
|
||||
name: 'CapsuleDetail',
|
||||
component: () => import('../views/CapsuleDetail.vue'),
|
||||
meta: {
|
||||
title: '胶囊详情 - ChronoMail',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/api-demo',
|
||||
name: 'ApiDemo',
|
||||
component: () => import('../views/ApiDemo.vue'),
|
||||
meta: {
|
||||
title: 'API示例 - ChronoMail',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title
|
||||
}
|
||||
|
||||
// 检查是否需要登录
|
||||
const isLoggedIn = localStorage.getItem('token')
|
||||
|
||||
if (to.matched.some(record => record.meta.requiresAuth) && !isLoggedIn) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
468
src/store/index.js
Normal file
468
src/store/index.js
Normal file
@@ -0,0 +1,468 @@
|
||||
import { reactive } from 'vue'
|
||||
import api from '../api'
|
||||
|
||||
// 用户状态
|
||||
export const userState = reactive({
|
||||
isLoggedIn: false,
|
||||
token: '',
|
||||
refreshToken: '',
|
||||
userInfo: {
|
||||
userId: '',
|
||||
username: '',
|
||||
email: '',
|
||||
avatar: ''
|
||||
},
|
||||
subscription: {
|
||||
plan: 'FREE',
|
||||
remainingMails: 0,
|
||||
maxAttachmentSize: 0,
|
||||
features: {
|
||||
advancedTriggers: false,
|
||||
customCapsules: false,
|
||||
aiAssistant: false
|
||||
},
|
||||
expireDate: null
|
||||
}
|
||||
})
|
||||
|
||||
// 邮件状态
|
||||
export const mailState = reactive({
|
||||
inboxList: [],
|
||||
sentList: [],
|
||||
draftList: [],
|
||||
currentMail: null,
|
||||
loading: false,
|
||||
pagination: {
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0
|
||||
}
|
||||
})
|
||||
|
||||
// 胶囊状态
|
||||
export const capsuleState = reactive({
|
||||
capsules: [],
|
||||
scene: 'SPACE',
|
||||
background: '',
|
||||
loading: false
|
||||
})
|
||||
|
||||
// 时间线状态
|
||||
export const timelineState = reactive({
|
||||
timeline: [],
|
||||
loading: false,
|
||||
filter: {
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
type: 'ALL'
|
||||
}
|
||||
})
|
||||
|
||||
// 统计数据状态
|
||||
export const statisticsState = reactive({
|
||||
totalSent: 0,
|
||||
totalReceived: 0,
|
||||
timeTravelDuration: 0,
|
||||
mostFrequentRecipient: '',
|
||||
mostCommonYear: new Date().getFullYear(),
|
||||
keywordCloud: [],
|
||||
monthlyStats: [],
|
||||
loading: false
|
||||
})
|
||||
|
||||
// 用户相关操作
|
||||
export const userActions = {
|
||||
// 登录
|
||||
async login(credentials) {
|
||||
const res = await api.auth.login(credentials)
|
||||
const { token, refreshToken, ...userInfo } = res.data
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('refreshToken', refreshToken)
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo))
|
||||
|
||||
// 更新状态
|
||||
userState.isLoggedIn = true
|
||||
userState.token = token
|
||||
userState.refreshToken = refreshToken
|
||||
userState.userInfo = userInfo
|
||||
|
||||
// 获取用户订阅信息
|
||||
await this.getSubscription()
|
||||
|
||||
return res
|
||||
},
|
||||
|
||||
// 注册
|
||||
async register(userData) {
|
||||
const res = await api.auth.register(userData)
|
||||
const { token, refreshToken, ...userInfo } = res.data
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('refreshToken', refreshToken)
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo))
|
||||
|
||||
// 更新状态
|
||||
userState.isLoggedIn = true
|
||||
userState.token = token
|
||||
userState.refreshToken = refreshToken
|
||||
userState.userInfo = userInfo
|
||||
|
||||
// 获取用户订阅信息
|
||||
await this.getSubscription()
|
||||
|
||||
return res
|
||||
},
|
||||
|
||||
// 退出登录
|
||||
async logout() {
|
||||
await api.auth.logout().catch(error => {
|
||||
console.error('退出登录请求失败:', error)
|
||||
})
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('userInfo')
|
||||
|
||||
// 重置状态
|
||||
userState.isLoggedIn = false
|
||||
userState.token = ''
|
||||
userState.refreshToken = ''
|
||||
userState.userInfo = {
|
||||
userId: '',
|
||||
username: '',
|
||||
email: '',
|
||||
avatar: ''
|
||||
}
|
||||
},
|
||||
|
||||
// 刷新token
|
||||
async refreshToken() {
|
||||
try {
|
||||
const refreshToken = userState.refreshToken || localStorage.getItem('refreshToken')
|
||||
if (!refreshToken) {
|
||||
throw new Error('没有刷新令牌')
|
||||
}
|
||||
|
||||
const res = await api.auth.refreshToken(refreshToken)
|
||||
const { token: newToken, refreshToken: newRefreshToken } = res.data
|
||||
|
||||
// 更新本地存储
|
||||
localStorage.setItem('token', newToken)
|
||||
localStorage.setItem('refreshToken', newRefreshToken)
|
||||
|
||||
// 更新状态
|
||||
userState.token = newToken
|
||||
userState.refreshToken = newRefreshToken
|
||||
|
||||
return res
|
||||
} catch (error) {
|
||||
// 刷新失败,退出登录
|
||||
await this.logout()
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 获取用户订阅信息
|
||||
async getSubscription() {
|
||||
try {
|
||||
const res = await api.user.getSubscription()
|
||||
userState.subscription = res.data
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('获取订阅信息失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 获取用户信息
|
||||
async fetchUserInfo() {
|
||||
if (!userState.token) return;
|
||||
|
||||
const response = await api.user.getUserInfo();
|
||||
if (response.data.code === 200) {
|
||||
userState.userInfo = response.data.data;
|
||||
localStorage.setItem('userInfo', JSON.stringify(response.data.data));
|
||||
}
|
||||
},
|
||||
|
||||
// 初始化用户状态(从本地存储恢复)
|
||||
initUserState() {
|
||||
const token = localStorage.getItem('token')
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
const userInfoStr = localStorage.getItem('userInfo')
|
||||
|
||||
if (token && userInfoStr) {
|
||||
try {
|
||||
const userInfo = JSON.parse(userInfoStr)
|
||||
userState.isLoggedIn = true
|
||||
userState.token = token
|
||||
userState.refreshToken = refreshToken || ''
|
||||
userState.userInfo = userInfo
|
||||
|
||||
// 获取订阅信息
|
||||
this.getSubscription()
|
||||
} catch (error) {
|
||||
console.error('解析用户信息失败:', error)
|
||||
// 清除无效数据
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('userInfo')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 邮件相关操作
|
||||
export const mailActions = {
|
||||
// 获取邮件列表
|
||||
async getMails(type = 'INBOX', page = 1, size = 10, status = '') {
|
||||
try {
|
||||
mailState.loading = true
|
||||
const params = { type, page, size }
|
||||
if (status) params.status = status
|
||||
|
||||
const res = await api.mail.getMails(params)
|
||||
|
||||
// 根据类型更新不同的列表
|
||||
if (type === 'INBOX') {
|
||||
mailState.inboxList = res.data.list
|
||||
} else if (type === 'SENT') {
|
||||
mailState.sentList = res.data.list
|
||||
} else if (type === 'DRAFT') {
|
||||
mailState.draftList = res.data.list
|
||||
}
|
||||
|
||||
// 更新分页信息
|
||||
mailState.pagination = {
|
||||
page: res.data.page,
|
||||
size: res.data.size,
|
||||
total: res.data.total
|
||||
}
|
||||
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('获取邮件列表失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
mailState.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 获取邮件详情
|
||||
async getMailDetail(mailId) {
|
||||
try {
|
||||
mailState.loading = true
|
||||
const res = await api.mail.getMailDetail(mailId)
|
||||
mailState.currentMail = res.data
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('获取邮件详情失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
mailState.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 创建邮件
|
||||
async createMail(mailData) {
|
||||
try {
|
||||
const res = await api.mail.createMail(mailData)
|
||||
|
||||
// 如果是草稿,添加到草稿列表
|
||||
if (mailData.status === 'DRAFT') {
|
||||
await this.getMails('DRAFT')
|
||||
}
|
||||
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('创建邮件失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 更新邮件
|
||||
async updateMail(mailId, mailData) {
|
||||
try {
|
||||
const res = await api.mail.updateMail(mailId, mailData)
|
||||
|
||||
// 更新当前邮件
|
||||
if (mailState.currentMail && mailState.currentMail.mailId === mailId) {
|
||||
await this.getMailDetail(mailId)
|
||||
}
|
||||
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('更新邮件失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 删除邮件
|
||||
async deleteMail(mailId) {
|
||||
try {
|
||||
const res = await api.mail.deleteMail(mailId)
|
||||
|
||||
// 从各个列表中移除该邮件
|
||||
mailState.inboxList = mailState.inboxList.filter(mail => mail.mailId !== mailId)
|
||||
mailState.sentList = mailState.sentList.filter(mail => mail.mailId !== mailId)
|
||||
mailState.draftList = mailState.draftList.filter(mail => mail.mailId !== mailId)
|
||||
|
||||
// 如果是当前邮件,清空
|
||||
if (mailState.currentMail && mailState.currentMail.mailId === mailId) {
|
||||
mailState.currentMail = null
|
||||
}
|
||||
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('删除邮件失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 撤销邮件
|
||||
async revokeMail(mailId) {
|
||||
try {
|
||||
const res = await api.mail.revokeMail(mailId)
|
||||
|
||||
// 更新列表中的邮件状态
|
||||
const updateMailStatus = (mailList) => {
|
||||
const mail = mailList.find(m => m.mailId === mailId)
|
||||
if (mail) {
|
||||
mail.status = 'REVOKED'
|
||||
}
|
||||
}
|
||||
|
||||
updateMailStatus(mailState.inboxList)
|
||||
updateMailStatus(mailState.sentList)
|
||||
|
||||
// 更新当前邮件
|
||||
if (mailState.currentMail && mailState.currentMail.mailId === mailId) {
|
||||
mailState.currentMail.status = 'REVOKED'
|
||||
}
|
||||
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('撤销邮件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 胶囊相关操作
|
||||
export const capsuleActions = {
|
||||
// 获取胶囊列表
|
||||
async getCapsules() {
|
||||
try {
|
||||
capsuleState.loading = true
|
||||
const res = await api.capsule.getCapsules()
|
||||
capsuleState.capsules = res.data.capsules
|
||||
capsuleState.scene = res.data.scene
|
||||
capsuleState.background = res.data.background
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('获取胶囊列表失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
capsuleState.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 更新胶囊样式
|
||||
async updateCapsuleStyle(capsuleId, style) {
|
||||
try {
|
||||
const res = await api.capsule.updateCapsuleStyle(capsuleId, style)
|
||||
|
||||
// 更新本地胶囊样式
|
||||
const capsule = capsuleState.capsules.find(c => c.capsuleId === capsuleId)
|
||||
if (capsule) {
|
||||
capsule.style = style
|
||||
}
|
||||
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('更新胶囊样式失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 时间线相关操作
|
||||
export const timelineActions = {
|
||||
// 获取时间线
|
||||
async getTimeline(filter = {}) {
|
||||
try {
|
||||
timelineState.loading = true
|
||||
timelineState.filter = { ...timelineState.filter, ...filter }
|
||||
|
||||
const res = await api.user.getTimeline(timelineState.filter)
|
||||
timelineState.timeline = res.data.timeline
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('获取时间线失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
timelineState.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 统计数据相关操作
|
||||
export const statisticsActions = {
|
||||
// 获取统计数据
|
||||
async getStatistics() {
|
||||
try {
|
||||
statisticsState.loading = true
|
||||
const res = await api.user.getStatistics()
|
||||
|
||||
// 更新统计数据
|
||||
Object.assign(statisticsState, res.data)
|
||||
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
statisticsState.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI助手相关操作
|
||||
export const aiActions = {
|
||||
// 写作辅助
|
||||
async writingAssistant(data) {
|
||||
try {
|
||||
const res = await api.ai.writingAssistant(data)
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('写作辅助失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 情感分析
|
||||
async sentimentAnalysis(content) {
|
||||
try {
|
||||
const res = await api.ai.sentimentAnalysis(content)
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('情感分析失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 未来预测
|
||||
async futurePrediction(data) {
|
||||
try {
|
||||
const res = await api.ai.futurePrediction(data)
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error('未来预测失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
254
src/utils/index.js
Normal file
254
src/utils/index.js
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 格式化日期
|
||||
* @param {Date|string} date 日期对象或ISO字符串
|
||||
* @param {string} format 格式化模板,默认 'YYYY-MM-DD'
|
||||
* @returns {string} 格式化后的日期字符串
|
||||
*/
|
||||
export function formatDate(date, format = 'YYYY-MM-DD') {
|
||||
if (!date) return ''
|
||||
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||
|
||||
return format
|
||||
.replace('YYYY', year)
|
||||
.replace('MM', month)
|
||||
.replace('DD', day)
|
||||
.replace('HH', hours)
|
||||
.replace('mm', minutes)
|
||||
.replace('ss', seconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个日期之间的天数差
|
||||
* @param {Date|string} startDate 开始日期
|
||||
* @param {Date|string} endDate 结束日期
|
||||
* @returns {number} 天数差
|
||||
*/
|
||||
export function daysBetween(startDate, endDate) {
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
|
||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) return 0
|
||||
|
||||
const diffTime = Math.abs(end - start)
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
return diffDays
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算剩余时间(倒计时)
|
||||
* @param {Date|string} targetDate 目标日期
|
||||
* @returns {Object} 包含天、时、分、秒的对象
|
||||
*/
|
||||
export function countdown(targetDate) {
|
||||
const target = new Date(targetDate)
|
||||
const now = new Date()
|
||||
|
||||
if (isNaN(target.getTime())) return { days: 0, hours: 0, minutes: 0, seconds: 0 }
|
||||
|
||||
const diffTime = target - now
|
||||
|
||||
if (diffTime <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 }
|
||||
|
||||
const days = Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor((diffTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diffTime % (1000 * 60 * 60)) / (1000 * 60))
|
||||
const seconds = Math.floor((diffTime % (1000 * 60)) / 1000)
|
||||
|
||||
return { days, hours, minutes, seconds }
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} size 文件大小(字节)
|
||||
* @returns {string} 格式化后的文件大小
|
||||
*/
|
||||
export function formatFileSize(size) {
|
||||
if (!size || size === 0) return '0 B'
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const index = Math.floor(Math.log(size) / Math.log(1024))
|
||||
|
||||
return `${(size / Math.pow(1024, index)).toFixed(2)} ${units[index]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机ID
|
||||
* @param {number} length ID长度,默认8
|
||||
* @returns {string} 随机ID
|
||||
*/
|
||||
export function generateId(length = 8) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱格式
|
||||
* @param {string} email 邮箱地址
|
||||
* @returns {boolean} 是否有效
|
||||
*/
|
||||
export function validateEmail(email) {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return re.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码强度
|
||||
* @param {string} password 密码
|
||||
* @returns {Object} 包含强度等级和提示的对象
|
||||
*/
|
||||
export function validatePassword(password) {
|
||||
if (!password) {
|
||||
return { strength: 0, message: '请输入密码' }
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return { strength: 1, message: '密码长度至少6位' }
|
||||
}
|
||||
|
||||
let strength = 0
|
||||
|
||||
// 长度加分
|
||||
if (password.length >= 8) strength += 1
|
||||
if (password.length >= 12) strength += 1
|
||||
|
||||
// 包含数字
|
||||
if (/\d/.test(password)) strength += 1
|
||||
|
||||
// 包含小写字母
|
||||
if (/[a-z]/.test(password)) strength += 1
|
||||
|
||||
// 包含大写字母
|
||||
if (/[A-Z]/.test(password)) strength += 1
|
||||
|
||||
// 包含特殊字符
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength += 1
|
||||
|
||||
let message = ''
|
||||
if (strength <= 2) {
|
||||
message = '密码强度较弱'
|
||||
} else if (strength <= 4) {
|
||||
message = '密码强度中等'
|
||||
} else {
|
||||
message = '密码强度较强'
|
||||
}
|
||||
|
||||
return { strength, message }
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝对象
|
||||
* @param {Object} obj 要拷贝的对象
|
||||
* @returns {Object} 拷贝后的对象
|
||||
*/
|
||||
export function deepClone(obj) {
|
||||
if (obj === null || typeof obj !== 'object') return obj
|
||||
|
||||
if (obj instanceof Date) return new Date(obj)
|
||||
if (obj instanceof Array) return obj.map(item => deepClone(item))
|
||||
if (typeof obj === 'object') {
|
||||
const clonedObj = {}
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
clonedObj[key] = deepClone(obj[key])
|
||||
}
|
||||
}
|
||||
return clonedObj
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param {Function} func 要防抖的函数
|
||||
* @param {number} wait 等待时间(毫秒)
|
||||
* @returns {Function} 防抖后的函数
|
||||
*/
|
||||
export function debounce(func, wait) {
|
||||
let timeout
|
||||
|
||||
return function(...args) {
|
||||
const context = this
|
||||
clearTimeout(timeout)
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
func.apply(context, args)
|
||||
}, wait)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param {Function} func 要节流的函数
|
||||
* @param {number} wait 等待时间(毫秒)
|
||||
* @returns {Function} 节流后的函数
|
||||
*/
|
||||
export function throttle(func, wait) {
|
||||
let lastTime = 0
|
||||
|
||||
return function(...args) {
|
||||
const context = this
|
||||
const now = Date.now()
|
||||
|
||||
if (now - lastTime >= wait) {
|
||||
lastTime = now
|
||||
func.apply(context, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @param {string} filename 文件名
|
||||
* @returns {string} 文件扩展名
|
||||
*/
|
||||
export function getFileExtension(filename) {
|
||||
if (!filename) return ''
|
||||
|
||||
const parts = filename.split('.')
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为图片文件
|
||||
* @param {string} filename 文件名或扩展名
|
||||
* @returns {boolean} 是否为图片
|
||||
*/
|
||||
export function isImageFile(filename) {
|
||||
const ext = getFileExtension(filename)
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为视频文件
|
||||
* @param {string} filename 文件名或扩展名
|
||||
* @returns {boolean} 是否为视频
|
||||
*/
|
||||
export function isVideoFile(filename) {
|
||||
const ext = getFileExtension(filename)
|
||||
return ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为音频文件
|
||||
* @param {string} filename 文件名或扩展名
|
||||
* @returns {boolean} 是否为音频
|
||||
*/
|
||||
export function isAudioFile(filename) {
|
||||
const ext = getFileExtension(filename)
|
||||
return ['mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a'].includes(ext)
|
||||
}
|
||||
489
src/views/ApiDemo.vue
Normal file
489
src/views/ApiDemo.vue
Normal file
@@ -0,0 +1,489 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="space-background">
|
||||
<div class="stars" ref="stars"></div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<van-nav-bar
|
||||
title="API示例"
|
||||
left-arrow
|
||||
@click-left="$router.go(-1)"
|
||||
/>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3 class="text-accent mb-20">用户认证示例</h3>
|
||||
|
||||
<div class="glass-card p-20 mb-20">
|
||||
<van-field
|
||||
v-model="loginForm.email"
|
||||
label="邮箱"
|
||||
placeholder="请输入邮箱"
|
||||
clearable
|
||||
/>
|
||||
<van-field
|
||||
v-model="loginForm.password"
|
||||
label="密码"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
clearable
|
||||
class="mt-10"
|
||||
/>
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
class="mt-20"
|
||||
:loading="loginLoading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3 class="text-accent mb-20">邮件操作示例</h3>
|
||||
|
||||
<div class="glass-card p-20 mb-20">
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
class="mb-10"
|
||||
:loading="mailLoading"
|
||||
@click="fetchMails"
|
||||
>
|
||||
获取邮件列表
|
||||
</van-button>
|
||||
|
||||
<van-button
|
||||
type="default"
|
||||
block
|
||||
class="mb-10"
|
||||
:loading="createMailLoading"
|
||||
@click="createSampleMail"
|
||||
>
|
||||
创建示例邮件
|
||||
</van-button>
|
||||
|
||||
<van-button
|
||||
type="default"
|
||||
block
|
||||
@click="showMailDialog = true"
|
||||
>
|
||||
查看邮件详情
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3 class="text-accent mb-20">胶囊操作示例</h3>
|
||||
|
||||
<div class="glass-card p-20 mb-20">
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="capsuleLoading"
|
||||
@click="fetchCapsules"
|
||||
>
|
||||
获取胶囊列表
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3 class="text-accent mb-20">AI助手示例</h3>
|
||||
|
||||
<div class="glass-card p-20 mb-20">
|
||||
<van-field
|
||||
v-model="aiPrompt"
|
||||
label="提示词"
|
||||
placeholder="请输入提示词"
|
||||
clearable
|
||||
/>
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
class="mt-20"
|
||||
:loading="aiLoading"
|
||||
@click="callWritingAssistant"
|
||||
>
|
||||
AI写作辅助
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件列表展示 -->
|
||||
<div v-if="mails.length > 0" class="demo-section">
|
||||
<h3 class="text-accent mb-20">邮件列表</h3>
|
||||
|
||||
<div
|
||||
v-for="mail in mails"
|
||||
:key="mail.mailId"
|
||||
class="list-item mb-10"
|
||||
@click="selectMail(mail)"
|
||||
>
|
||||
<div class="flex-between">
|
||||
<div class="flex-1">
|
||||
<div class="text-primary mb-5">{{ mail.title }}</div>
|
||||
<div class="text-secondary text-sm">{{ formatDate(mail.sendTime) }}</div>
|
||||
</div>
|
||||
<div :class="`mail-status status-${mail.status.toLowerCase()}`">
|
||||
{{ getStatusText(mail.status) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 胶囊列表展示 -->
|
||||
<div v-if="capsules.length > 0" class="demo-section">
|
||||
<h3 class="text-accent mb-20">胶囊列表</h3>
|
||||
|
||||
<div class="capsules-container">
|
||||
<div
|
||||
v-for="capsule in capsules"
|
||||
:key="capsule.capsuleId"
|
||||
class="time-capsule"
|
||||
:class="`capsule-style-${(capsule.style % 5) + 1}`"
|
||||
@click="selectCapsule(capsule)"
|
||||
>
|
||||
<div class="capsule-title text-ellipsis">{{ capsule.title }}</div>
|
||||
<div class="capsule-time">{{ formatDate(capsule.deliveryTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件详情弹窗 -->
|
||||
<van-popup v-model:show="showMailDialog" position="bottom" round>
|
||||
<div class="mail-detail-popup">
|
||||
<div class="popup-header">
|
||||
<h3>邮件详情</h3>
|
||||
<van-icon name="cross" @click="showMailDialog = false" />
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMail" class="popup-content">
|
||||
<div class="mail-field">
|
||||
<div class="field-label">标题</div>
|
||||
<div class="field-value">{{ selectedMail.title }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mail-field">
|
||||
<div class="field-label">内容</div>
|
||||
<div class="field-value">{{ selectedMail.content }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mail-field">
|
||||
<div class="field-label">发送时间</div>
|
||||
<div class="field-value">{{ formatDate(selectedMail.sendTime) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mail-field">
|
||||
<div class="field-label">状态</div>
|
||||
<div :class="`mail-status status-${selectedMail.status.toLowerCase()}`">
|
||||
{{ getStatusText(selectedMail.status) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showFailToast, showSuccessToast } from 'vant'
|
||||
import { userActions, mailActions, capsuleActions, aiActions } from '../store'
|
||||
import { formatDate } from '../utils'
|
||||
|
||||
export default {
|
||||
name: 'ApiDemo',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const stars = ref(null)
|
||||
|
||||
// 登录表单
|
||||
const loginForm = reactive({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
const loginLoading = ref(false)
|
||||
|
||||
// 邮件相关
|
||||
const mails = ref([])
|
||||
const mailLoading = ref(false)
|
||||
const createMailLoading = ref(false)
|
||||
const selectedMail = ref(null)
|
||||
const showMailDialog = ref(false)
|
||||
|
||||
// 胶囊相关
|
||||
const capsules = ref([])
|
||||
const capsuleLoading = ref(false)
|
||||
|
||||
// AI相关
|
||||
const aiPrompt = ref('')
|
||||
const aiLoading = ref(false)
|
||||
|
||||
// 初始化星空背景
|
||||
const initStars = () => {
|
||||
if (!stars.value) return
|
||||
|
||||
const starsContainer = stars.value
|
||||
const starCount = 100
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const star = document.createElement('div')
|
||||
star.className = 'star'
|
||||
|
||||
// 随机大小
|
||||
const size = Math.random() * 3 + 1
|
||||
star.style.width = `${size}px`
|
||||
star.style.height = `${size}px`
|
||||
|
||||
// 随机位置
|
||||
star.style.left = `${Math.random() * 100}%`
|
||||
star.style.top = `${Math.random() * 100}%`
|
||||
|
||||
// 随机动画延迟
|
||||
star.style.animationDelay = `${Math.random() * 4}s`
|
||||
|
||||
starsContainer.appendChild(star)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
if (!loginForm.email || !loginForm.password) {
|
||||
showFailToast('请输入邮箱和密码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loginLoading.value = true
|
||||
const response = await userActions.login(loginForm)
|
||||
showSuccessToast('登录成功')
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取邮件列表
|
||||
const fetchMails = async () => {
|
||||
try {
|
||||
mailLoading.value = true
|
||||
const response = await mailActions.getMails({ type: 'SENT', page: 1, size: 10 })
|
||||
mails.value = response.data.list
|
||||
showSuccessToast('获取邮件列表成功')
|
||||
} catch (error) {
|
||||
console.error('获取邮件列表失败:', error)
|
||||
} finally {
|
||||
mailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建示例邮件
|
||||
const createSampleMail = async () => {
|
||||
try {
|
||||
createMailLoading.value = true
|
||||
|
||||
const futureDate = new Date()
|
||||
futureDate.setFullYear(futureDate.getFullYear() + 1)
|
||||
|
||||
const mailData = {
|
||||
title: '给未来自己的一封信',
|
||||
content: '这是来自过去的一封信,希望未来的你一切都好!',
|
||||
recipientType: 'SELF',
|
||||
sendTime: futureDate.toISOString(),
|
||||
triggerType: 'TIME',
|
||||
isEncrypted: false,
|
||||
capsuleStyle: '1'
|
||||
}
|
||||
|
||||
const response = await mailActions.createMail(mailData)
|
||||
showSuccessToast('创建邮件成功')
|
||||
|
||||
// 刷新邮件列表
|
||||
if (mails.value.length > 0) {
|
||||
fetchMails()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建邮件失败:', error)
|
||||
} finally {
|
||||
createMailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取胶囊列表
|
||||
const fetchCapsules = async () => {
|
||||
try {
|
||||
capsuleLoading.value = true
|
||||
const response = await capsuleActions.getCapsules()
|
||||
capsules.value = response.data.capsules
|
||||
showSuccessToast('获取胶囊列表成功')
|
||||
} catch (error) {
|
||||
console.error('获取胶囊列表失败:', error)
|
||||
} finally {
|
||||
capsuleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// AI写作辅助
|
||||
const callWritingAssistant = async () => {
|
||||
if (!aiPrompt.value) {
|
||||
showFailToast('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
aiLoading.value = true
|
||||
|
||||
const data = {
|
||||
prompt: aiPrompt.value,
|
||||
type: 'DRAFT',
|
||||
tone: 'EMOTIONAL',
|
||||
length: 'MEDIUM'
|
||||
}
|
||||
|
||||
const response = await aiActions.writingAssistant(data)
|
||||
showSuccessToast('AI写作辅助完成')
|
||||
|
||||
// 可以在这里处理AI生成的内容
|
||||
console.log('AI生成的内容:', response.data.content)
|
||||
} catch (error) {
|
||||
console.error('AI写作辅助失败:', error)
|
||||
} finally {
|
||||
aiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 选择邮件
|
||||
const selectMail = (mail) => {
|
||||
selectedMail.value = mail
|
||||
showMailDialog.value = true
|
||||
}
|
||||
|
||||
// 选择胶囊
|
||||
const selectCapsule = (capsule) => {
|
||||
router.push(`/capsule/${capsule.capsuleId}`)
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
'DRAFT': '草稿',
|
||||
'PENDING': '待发送',
|
||||
'DELIVERING': '投递中',
|
||||
'DELIVERED': '已送达',
|
||||
'REVOKED': '已撤销'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initStars()
|
||||
})
|
||||
|
||||
return {
|
||||
stars,
|
||||
loginForm,
|
||||
loginLoading,
|
||||
mails,
|
||||
mailLoading,
|
||||
createMailLoading,
|
||||
selectedMail,
|
||||
showMailDialog,
|
||||
capsules,
|
||||
capsuleLoading,
|
||||
aiPrompt,
|
||||
aiLoading,
|
||||
handleLogin,
|
||||
fetchMails,
|
||||
createSampleMail,
|
||||
fetchCapsules,
|
||||
callWritingAssistant,
|
||||
selectMail,
|
||||
selectCapsule,
|
||||
getStatusText,
|
||||
formatDate
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.capsules-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.time-capsule {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.capsule-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.capsule-time {
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.mail-detail-popup {
|
||||
padding: 20px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.popup-header h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mail-field {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
728
src/views/CapsuleDetail.vue
Normal file
728
src/views/CapsuleDetail.vue
Normal file
@@ -0,0 +1,728 @@
|
||||
<template>
|
||||
<div class="capsule-detail-container">
|
||||
<!-- 深空背景 -->
|
||||
<div class="space-background">
|
||||
<div class="stars" ref="stars"></div>
|
||||
<div class="floating-particles" ref="particles"></div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<div class="header glass-card">
|
||||
<van-icon name="arrow-left" size="24" @click="goBack" />
|
||||
<h2>胶囊详情</h2>
|
||||
<van-icon name="share-o" size="24" @click="shareCapsule" />
|
||||
</div>
|
||||
|
||||
<!-- 胶囊主体 -->
|
||||
<div class="capsule-container" v-if="capsuleData">
|
||||
<!-- 胶囊3D模型 -->
|
||||
<div class="capsule-3d" ref="capsule3d">
|
||||
<div class="capsule-model" :class="{ 'opened': isCapsuleOpened, 'opening': isCapsuleOpening }">
|
||||
<div class="capsule-top" :style="{ transform: isCapsuleOpened ? 'rotateX(-45deg) translateY(-10px)' : '' }"></div>
|
||||
<div class="capsule-bottom"></div>
|
||||
<div class="capsule-glow"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 胶囊信息 -->
|
||||
<div class="capsule-info glass-card">
|
||||
<div class="capsule-title">{{ capsuleData.title }}</div>
|
||||
<div class="capsule-meta">
|
||||
<div class="meta-item">
|
||||
<van-icon name="user-o" />
|
||||
<span>{{ capsuleData.sender.username }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<van-icon name="clock-o" />
|
||||
<span>{{ formatDate(capsuleData.sendTime) }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<van-icon name="calendar-o" />
|
||||
<span>{{ formatDate(capsuleData.deliveryTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 胶囊内容 -->
|
||||
<div class="capsule-content glass-card" v-if="isCapsuleOpened">
|
||||
<div class="content-text" v-html="capsuleData.content"></div>
|
||||
|
||||
<!-- 附件 -->
|
||||
<div class="attachments" v-if="capsuleData.attachments && capsuleData.attachments.length > 0">
|
||||
<h4>附件</h4>
|
||||
<div class="attachment-list">
|
||||
<div
|
||||
v-for="attachment in capsuleData.attachments"
|
||||
:key="attachment.id"
|
||||
class="attachment-item"
|
||||
@click="viewAttachment(attachment)"
|
||||
>
|
||||
<van-icon :name="getAttachmentIcon(attachment.type)" />
|
||||
<span>{{ attachment.url.split('/').pop() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<van-button
|
||||
v-if="!isCapsuleOpened"
|
||||
round
|
||||
block
|
||||
class="open-button"
|
||||
@click="openCapsule"
|
||||
:disabled="isCapsuleOpening"
|
||||
>
|
||||
{{ isCapsuleOpening ? '开启中...' : '开启胶囊' }}
|
||||
</van-button>
|
||||
|
||||
<van-button
|
||||
v-if="isCapsuleOpened"
|
||||
round
|
||||
block
|
||||
class="reply-button"
|
||||
@click="replyToCapsule"
|
||||
>
|
||||
回复
|
||||
</van-button>
|
||||
|
||||
<van-button
|
||||
v-if="isMyCapsule && !isCapsuleOpened"
|
||||
round
|
||||
block
|
||||
class="edit-button"
|
||||
@click="editCapsule"
|
||||
>
|
||||
编辑
|
||||
</van-button>
|
||||
|
||||
<van-button
|
||||
v-if="isMyCapsule && !isCapsuleOpened"
|
||||
round
|
||||
block
|
||||
class="delete-button"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
撤销
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div class="loading-container" v-else>
|
||||
<van-loading type="spinner" color="#00D4FF" size="24px">加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<!-- 附件预览弹窗 -->
|
||||
<van-popup v-model:show="showAttachment" position="center" :style="{ width: '90%', height: '80%' }">
|
||||
<div class="attachment-preview">
|
||||
<div class="preview-header">
|
||||
<h3>{{ currentAttachment.url.split('/').pop() }}</h3>
|
||||
<van-icon name="cross" @click="showAttachment = false" />
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<img v-if="currentAttachment.type === 'IMAGE'" :src="currentAttachment.url" alt="图片预览" />
|
||||
<video v-else-if="currentAttachment.type === 'VIDEO'" :src="currentAttachment.url" controls></video>
|
||||
<audio v-else-if="currentAttachment.type === 'VOICE'" :src="currentAttachment.url" controls></audio>
|
||||
<div v-else class="file-preview">
|
||||
<van-icon name="description" size="60" />
|
||||
<p>无法预览此文件类型</p>
|
||||
<van-button type="primary" @click="downloadAttachment">下载</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { showFailToast, showSuccessToast, Dialog } from 'vant'
|
||||
import { mailActions } from '../store'
|
||||
|
||||
export default {
|
||||
name: 'CapsuleDetail',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const stars = ref(null)
|
||||
const particles = ref(null)
|
||||
const capsule3d = ref(null)
|
||||
|
||||
// 胶囊数据
|
||||
const capsuleData = ref(null)
|
||||
const isCapsuleOpened = ref(false)
|
||||
const isCapsuleOpening = ref(false)
|
||||
const isMyCapsule = ref(false)
|
||||
|
||||
// 附件预览
|
||||
const showAttachment = ref(false)
|
||||
const currentAttachment = ref({})
|
||||
|
||||
// 获取胶囊ID
|
||||
const mailId = computed(() => route.params.id)
|
||||
|
||||
// 生成星空背景
|
||||
const generateStars = () => {
|
||||
if (!stars.value) return
|
||||
|
||||
const starsContainer = stars.value
|
||||
const starCount = 200
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const star = document.createElement('div')
|
||||
star.className = 'star'
|
||||
|
||||
// 随机位置
|
||||
const left = Math.random() * 100
|
||||
const top = Math.random() * 100
|
||||
|
||||
// 随机大小
|
||||
const size = Math.random() * 3 + 1
|
||||
|
||||
// 随机动画延迟
|
||||
const delay = Math.random() * 4
|
||||
|
||||
star.style.left = `${left}%`
|
||||
star.style.top = `${top}%`
|
||||
star.style.width = `${size}px`
|
||||
star.style.height = `${size}px`
|
||||
star.style.animationDelay = `${delay}s`
|
||||
|
||||
starsContainer.appendChild(star)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成漂浮粒子
|
||||
const generateParticles = () => {
|
||||
if (!particles.value) return
|
||||
|
||||
const particlesContainer = particles.value
|
||||
const particleCount = 30
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const particle = document.createElement('div')
|
||||
particle.className = 'particle'
|
||||
|
||||
// 随机位置
|
||||
const left = Math.random() * 100
|
||||
const top = Math.random() * 100
|
||||
|
||||
// 随机大小
|
||||
const size = Math.random() * 6 + 2
|
||||
|
||||
// 随机动画延迟和持续时间
|
||||
const delay = Math.random() * 10
|
||||
const duration = Math.random() * 20 + 20
|
||||
|
||||
particle.style.left = `${left}%`
|
||||
particle.style.top = `${top}%`
|
||||
particle.style.width = `${size}px`
|
||||
particle.style.height = `${size}px`
|
||||
particle.style.animationDelay = `${delay}s`
|
||||
particle.style.animationDuration = `${duration}s`
|
||||
|
||||
particlesContainer.appendChild(particle)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载胶囊数据
|
||||
const loadCapsuleData = async () => {
|
||||
try {
|
||||
const response = await mailActions.getMailDetail(mailId.value)
|
||||
|
||||
if (response.code === 200) {
|
||||
capsuleData.value = response.data
|
||||
|
||||
// 判断是否是自己的胶囊
|
||||
const userStr = localStorage.getItem('user')
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr)
|
||||
isMyCapsule.value = capsuleData.value.sender.userId === user.userId
|
||||
}
|
||||
|
||||
// 如果是已送达的胶囊,默认开启
|
||||
if (capsuleData.value.status === 'DELIVERED') {
|
||||
isCapsuleOpened.value = true
|
||||
}
|
||||
} else {
|
||||
showFailToast(response.message || '加载胶囊详情失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载胶囊详情失败:', error)
|
||||
showFailToast('加载胶囊详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// 开启胶囊
|
||||
const openCapsule = () => {
|
||||
isCapsuleOpening.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
isCapsuleOpening.value = false
|
||||
isCapsuleOpened.value = true
|
||||
|
||||
// 播放开启动画和音效
|
||||
if (capsule3d.value) {
|
||||
capsule3d.value.classList.add('opened')
|
||||
}
|
||||
|
||||
showSuccessToast('胶囊已开启')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 分享胶囊
|
||||
const shareCapsule = () => {
|
||||
showFailToast('分享功能开发中')
|
||||
}
|
||||
|
||||
// 回复胶囊
|
||||
const replyToCapsule = () => {
|
||||
router.push(`/compose?replyTo=${mailId.value}`)
|
||||
}
|
||||
|
||||
// 编辑胶囊
|
||||
const editCapsule = () => {
|
||||
router.push(`/compose?edit=${mailId.value}`)
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = () => {
|
||||
Dialog.confirm({
|
||||
title: '确认撤销',
|
||||
message: '确定要撤销这个胶囊吗?此操作不可恢复。',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
const response = await mailActions.revokeMail(mailId.value)
|
||||
if (response.code === 200) {
|
||||
showSuccessToast('胶囊已撤销')
|
||||
router.back()
|
||||
} else {
|
||||
showFailToast(response.message || '撤销失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('撤销胶囊失败:', error)
|
||||
showFailToast('撤销失败')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 取消操作
|
||||
})
|
||||
}
|
||||
|
||||
// 查看附件
|
||||
const viewAttachment = (attachment) => {
|
||||
currentAttachment.value = attachment
|
||||
showAttachment.value = true
|
||||
}
|
||||
|
||||
// 获取附件图标
|
||||
const getAttachmentIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'IMAGE':
|
||||
return 'photo-o'
|
||||
case 'VIDEO':
|
||||
return 'video-o'
|
||||
case 'VOICE':
|
||||
return 'music-o'
|
||||
default:
|
||||
return 'description'
|
||||
}
|
||||
}
|
||||
|
||||
// 下载附件
|
||||
const downloadAttachment = () => {
|
||||
// 实现下载逻辑
|
||||
showFailToast('下载功能开发中')
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateStars()
|
||||
generateParticles()
|
||||
loadCapsuleData()
|
||||
})
|
||||
|
||||
return {
|
||||
stars,
|
||||
particles,
|
||||
capsule3d,
|
||||
capsuleData,
|
||||
isCapsuleOpened,
|
||||
isCapsuleOpening,
|
||||
isMyCapsule,
|
||||
showAttachment,
|
||||
currentAttachment,
|
||||
formatDate,
|
||||
openCapsule,
|
||||
shareCapsule,
|
||||
replyToCapsule,
|
||||
editCapsule,
|
||||
confirmDelete,
|
||||
viewAttachment,
|
||||
getAttachmentIcon,
|
||||
downloadAttachment,
|
||||
goBack
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.capsule-detail-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
margin: 15px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.capsule-container {
|
||||
flex: 1;
|
||||
padding: 0 15px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.capsule-3d {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin: 30px 0;
|
||||
perspective: 1000px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.capsule-model {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
transition: all 1s ease;
|
||||
}
|
||||
|
||||
.capsule-model.opened {
|
||||
animation: glow 2s ease-in-out;
|
||||
}
|
||||
|
||||
.capsule-model.opening {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.capsule-top, .capsule-bottom {
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: linear-gradient(135deg, #1D3B5A, #0F1C2E);
|
||||
box-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
|
||||
transition: all 1s ease;
|
||||
}
|
||||
|
||||
.capsule-top {
|
||||
height: 60px;
|
||||
top: 25%;
|
||||
transform-origin: bottom center;
|
||||
border-bottom: 2px solid rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
|
||||
.capsule-bottom {
|
||||
height: 60px;
|
||||
top: 50%;
|
||||
border-top: 2px solid rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
|
||||
.capsule-glow {
|
||||
position: absolute;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 50%;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: radial-gradient(circle, rgba(0, 212, 255, 0.3) 0%, rgba(0, 212, 255, 0) 70%);
|
||||
animation: pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.capsule-info {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.capsule-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #00D4FF, #ffffff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.capsule-meta {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta-item span {
|
||||
margin-top: 5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.capsule-content {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.attachments h4 {
|
||||
margin: 0 0 15px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.attachment-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.attachment-item:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.attachment-item span {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.open-button {
|
||||
background: linear-gradient(135deg, var(--accent-color), #0099CC);
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.reply-button {
|
||||
background: linear-gradient(135deg, #4ECDC4, #2A9D8F);
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
background: linear-gradient(135deg, #FFD166, #F77F00);
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
background: linear-gradient(135deg, #E63946, #A61E4D);
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.preview-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-content img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-content video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.preview-content audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-preview p {
|
||||
margin: 15px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0) rotateY(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px) rotateY(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0% {
|
||||
box-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(0, 212, 255, 0.8);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 粒子效果 */
|
||||
.particle {
|
||||
position: absolute;
|
||||
background: radial-gradient(circle, rgba(0, 212, 255, 0.8) 0%, rgba(0, 212, 255, 0) 70%);
|
||||
border-radius: 50%;
|
||||
animation: float-particle linear infinite;
|
||||
}
|
||||
|
||||
@keyframes float-particle {
|
||||
0% {
|
||||
transform: translateY(100vh) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-100vh) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
755
src/views/Compose.vue
Normal file
755
src/views/Compose.vue
Normal file
@@ -0,0 +1,755 @@
|
||||
<template>
|
||||
<div class="compose-container">
|
||||
<!-- 深空背景 -->
|
||||
<div class="space-background">
|
||||
<div class="stars" ref="stars"></div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<div class="header glass-card">
|
||||
<van-icon name="arrow-left" size="24" @click="goBack" />
|
||||
<h2>撰写未来邮件</h2>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<!-- 撰写表单 -->
|
||||
<div class="compose-content">
|
||||
<div class="form-section glass-card p-20">
|
||||
<!-- 收件人选择 -->
|
||||
<div class="form-group">
|
||||
<h3>收件人</h3>
|
||||
<van-radio-group v-model="recipientType" direction="horizontal">
|
||||
<van-radio name="SELF">自己</van-radio>
|
||||
<van-radio name="SPECIFIC">他人</van-radio>
|
||||
<van-radio name="PUBLIC">任意有缘人</van-radio>
|
||||
</van-radio-group>
|
||||
|
||||
<van-field
|
||||
v-if="recipientType === 'SPECIFIC'"
|
||||
v-model="recipientEmail"
|
||||
placeholder="收件人邮箱"
|
||||
class="custom-field mt-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 发送时间选择 -->
|
||||
<div class="form-group mt-20">
|
||||
<h3>发送时间</h3>
|
||||
<van-radio-group v-model="timeType" direction="horizontal">
|
||||
<van-radio name="preset">预设时间</van-radio>
|
||||
<van-radio name="custom">自定义</van-radio>
|
||||
<van-radio name="condition">条件触发</van-radio>
|
||||
</van-radio-group>
|
||||
|
||||
<div v-if="timeType === 'preset'" class="preset-options mt-10">
|
||||
<van-button
|
||||
v-for="option in presetTimeOptions"
|
||||
:key="option.value"
|
||||
:type="selectedPresetTime === option.value ? 'primary' : 'default'"
|
||||
round
|
||||
size="small"
|
||||
class="preset-button"
|
||||
@click="selectPresetTime(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<van-datetime-picker
|
||||
v-if="timeType === 'custom'"
|
||||
v-model="customDeliveryDate"
|
||||
type="date"
|
||||
:min-date="minDate"
|
||||
class="custom-date-picker mt-10"
|
||||
/>
|
||||
|
||||
<div v-if="timeType === 'condition'" class="condition-options mt-10">
|
||||
<van-cell-group>
|
||||
<van-cell title="地点触发" is-link @click="showLocationPicker = true" />
|
||||
<van-cell title="事件触发" is-link @click="showEventPicker = true" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件内容 -->
|
||||
<div class="form-group mt-20">
|
||||
<h3>邮件内容</h3>
|
||||
<van-field
|
||||
v-model="subject"
|
||||
placeholder="标题"
|
||||
class="custom-field"
|
||||
/>
|
||||
<van-field
|
||||
v-model="content"
|
||||
type="textarea"
|
||||
placeholder="写下你想对未来的自己说的话..."
|
||||
rows="8"
|
||||
autosize
|
||||
class="custom-field mt-10"
|
||||
/>
|
||||
|
||||
<!-- 附件和多媒体 -->
|
||||
<div class="media-options mt-10">
|
||||
<van-uploader :after-read="afterRead" class="media-uploader">
|
||||
<van-button icon="photo-o" type="primary" plain round size="small">
|
||||
添加图片
|
||||
</van-button>
|
||||
</van-uploader>
|
||||
<van-button icon="volume-o" type="primary" plain round size="small" class="ml-10">
|
||||
添加语音
|
||||
</van-button>
|
||||
<van-button icon="video-o" type="primary" plain round size="small" class="ml-10">
|
||||
添加视频
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI助手 -->
|
||||
<div class="form-group mt-20">
|
||||
<h3>AI写作助手</h3>
|
||||
<van-cell-group>
|
||||
<van-cell title="生成开头" is-link @click="generateOpening" />
|
||||
<van-cell title="内容建议" is-link @click="generateSuggestions" />
|
||||
<van-cell title="情感分析" is-link @click="analyzeEmotion" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="footer-actions">
|
||||
<van-button round block class="save-button" @click="saveDraft">
|
||||
存入胶囊
|
||||
</van-button>
|
||||
<van-button round block type="primary" class="send-button" @click="sendMail">
|
||||
发送至未来
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 地点选择弹窗 -->
|
||||
<van-popup v-model:show="showLocationPicker" position="bottom">
|
||||
<van-area
|
||||
:area-list="areaList"
|
||||
@confirm="onLocationConfirm"
|
||||
@cancel="showLocationPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 事件选择弹窗 -->
|
||||
<van-popup v-model:show="showEventPicker" position="bottom" :style="{ height: '50%' }">
|
||||
<div class="event-picker">
|
||||
<h3>选择触发事件</h3>
|
||||
<van-cell-group>
|
||||
<van-cell
|
||||
v-for="event in triggerEvents"
|
||||
:key="event.id"
|
||||
:title="event.name"
|
||||
:label="event.description"
|
||||
@click="selectEvent(event)"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showLoadingToast, showSuccessToast, showFailToast, closeToast, Dialog } from 'vant'
|
||||
import { mailActions, aiActions } from '../store'
|
||||
|
||||
export default {
|
||||
name: 'Compose',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const stars = ref(null)
|
||||
|
||||
// 表单数据
|
||||
const recipientType = ref('SELF') // 对应API的SELF, SPECIFIC, PUBLIC
|
||||
const recipientEmail = ref('')
|
||||
const timeType = ref('preset') // preset, custom, condition
|
||||
const selectedPresetTime = ref('1year')
|
||||
const customDeliveryDate = ref(new Date(Date.now() + 365 * 24 * 60 * 60 * 1000))
|
||||
const subject = ref('')
|
||||
const content = ref('')
|
||||
const attachments = ref([]) // 附件列表
|
||||
const isEncrypted = ref(false) // 是否加密
|
||||
const capsuleStyle = ref('default') // 胶囊样式
|
||||
|
||||
// 弹窗控制
|
||||
const showLocationPicker = ref(false)
|
||||
const showEventPicker = ref(false)
|
||||
const selectedLocation = ref(null) // 选中的地点
|
||||
const selectedEvent = ref(null) // 选中的触发事件
|
||||
|
||||
// 最小日期为明天
|
||||
const minDate = computed(() => {
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return tomorrow
|
||||
})
|
||||
|
||||
// 预设时间选项
|
||||
const presetTimeOptions = [
|
||||
{ label: '1天后', value: '1day' },
|
||||
{ label: '1周后', value: '1week' },
|
||||
{ label: '1个月后', value: '1month' },
|
||||
{ label: '1年后', value: '1year' },
|
||||
{ label: '5年后', value: '5years' },
|
||||
{ label: '10年后', value: '10years' }
|
||||
]
|
||||
|
||||
// 触发事件选项
|
||||
const triggerEvents = [
|
||||
{
|
||||
id: 1,
|
||||
name: '人类登陆火星',
|
||||
description: '当检测到相关新闻时触发',
|
||||
keywords: ['火星', '登陆', '太空探索'],
|
||||
type: 'SPACE_EVENT'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '获得理想工作',
|
||||
description: '当您更新个人资料为在职状态时触发',
|
||||
keywords: ['工作', '职业', '就业'],
|
||||
type: 'CAREER_EVENT'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '结婚纪念日',
|
||||
description: '在每年的结婚纪念日触发',
|
||||
keywords: ['结婚', '纪念日', '婚礼'],
|
||||
type: 'PERSONAL_EVENT'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '孩子出生',
|
||||
description: '当您添加家庭成员信息时触发',
|
||||
keywords: ['孩子', '出生', '家庭'],
|
||||
type: 'FAMILY_EVENT'
|
||||
}
|
||||
]
|
||||
|
||||
// 模拟地区数据
|
||||
const areaList = {
|
||||
province_list: {
|
||||
110000: '北京市',
|
||||
120000: '天津市',
|
||||
310000: '上海市',
|
||||
440000: '广东省',
|
||||
330000: '浙江省',
|
||||
320000: '江苏省'
|
||||
},
|
||||
city_list: {
|
||||
110100: '北京市',
|
||||
120100: '天津市',
|
||||
310100: '上海市',
|
||||
440100: '广州市',
|
||||
440300: '深圳市',
|
||||
330100: '杭州市',
|
||||
320100: '南京市'
|
||||
},
|
||||
county_list: {
|
||||
110101: '东城区',
|
||||
110102: '西城区',
|
||||
440103: '荔湾区',
|
||||
440304: '福田区',
|
||||
330102: '上城区',
|
||||
320102: '玄武区'
|
||||
}
|
||||
}
|
||||
|
||||
// 生成星空背景
|
||||
const generateStars = () => {
|
||||
if (!stars.value) return
|
||||
|
||||
const starsContainer = stars.value
|
||||
const starCount = 100
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const star = document.createElement('div')
|
||||
star.className = 'star'
|
||||
|
||||
// 随机位置
|
||||
const left = Math.random() * 100
|
||||
const top = Math.random() * 100
|
||||
|
||||
// 随机大小
|
||||
const size = Math.random() * 3 + 1
|
||||
|
||||
// 随机动画延迟
|
||||
const delay = Math.random() * 4
|
||||
|
||||
star.style.left = `${left}%`
|
||||
star.style.top = `${top}%`
|
||||
star.style.width = `${size}px`
|
||||
star.style.height = `${size}px`
|
||||
star.style.animationDelay = `${delay}s`
|
||||
|
||||
starsContainer.appendChild(star)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 选择预设时间
|
||||
const selectPresetTime = (value) => {
|
||||
selectedPresetTime.value = value
|
||||
}
|
||||
|
||||
// 地点选择确认
|
||||
const onLocationConfirm = (values) => {
|
||||
showLocationPicker.value = false
|
||||
const locationName = values.map(item => item.name).join('/')
|
||||
selectedLocation.value = {
|
||||
city: values[1]?.name || '',
|
||||
province: values[0]?.name || '',
|
||||
district: values[2]?.name || ''
|
||||
}
|
||||
showFailToast(`已选择地点: ${locationName}`)
|
||||
}
|
||||
|
||||
// 选择触发事件
|
||||
const selectEvent = (event) => {
|
||||
showEventPicker.value = false
|
||||
selectedEvent.value = event
|
||||
showFailToast(`已选择触发事件: ${event.name}`)
|
||||
}
|
||||
|
||||
// 文件上传后处理
|
||||
const afterRead = (file) => {
|
||||
// 这里应该调用文件上传API
|
||||
// 模拟上传成功
|
||||
const attachment = {
|
||||
type: 'IMAGE',
|
||||
url: URL.createObjectURL(file.file),
|
||||
thumbnail: URL.createObjectURL(file.file),
|
||||
size: file.file.size
|
||||
}
|
||||
attachments.value.push(attachment)
|
||||
showSuccessToast(`已添加图片: ${file.file.name}`)
|
||||
}
|
||||
|
||||
// AI生成开头
|
||||
const generateOpening = async () => {
|
||||
try {
|
||||
showLoadingToast({
|
||||
message: '生成中...',
|
||||
forbidClick: true,
|
||||
})
|
||||
|
||||
const response = await aiActions.writingAssistant({
|
||||
prompt: '请为未来邮件生成一个开头',
|
||||
type: 'OUTLINE',
|
||||
tone: 'EMOTIONAL',
|
||||
length: 'SHORT',
|
||||
context: '写给未来的自己'
|
||||
})
|
||||
|
||||
closeToast()
|
||||
content.value = response.data.content
|
||||
showSuccessToast('已生成开头')
|
||||
} catch (error) {
|
||||
closeToast()
|
||||
showFailToast('生成失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// AI生成内容建议
|
||||
const generateSuggestions = async () => {
|
||||
try {
|
||||
showLoadingToast({
|
||||
message: '生成中...',
|
||||
forbidClick: true,
|
||||
})
|
||||
|
||||
const response = await aiActions.writingAssistant({
|
||||
prompt: '为未来邮件提供内容建议',
|
||||
type: 'DRAFT',
|
||||
tone: 'INSPIRATIONAL',
|
||||
length: 'MEDIUM',
|
||||
context: content.value || '写给未来的自己'
|
||||
})
|
||||
|
||||
closeToast()
|
||||
Dialog.alert({
|
||||
title: '内容建议',
|
||||
message: response.data.content,
|
||||
})
|
||||
} catch (error) {
|
||||
closeToast()
|
||||
showFailToast('生成失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// AI情感分析
|
||||
const analyzeEmotion = async () => {
|
||||
if (!content.value) {
|
||||
showFailToast('请先填写邮件内容')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
showLoadingToast({
|
||||
message: '分析中...',
|
||||
forbidClick: true,
|
||||
})
|
||||
|
||||
const response = await aiActions.sentimentAnalysis({
|
||||
content: content.value
|
||||
})
|
||||
|
||||
closeToast()
|
||||
const sentiment = response.data.sentiment
|
||||
const emotions = response.data.emotions.map(e => e.type).join('、')
|
||||
const summary = response.data.summary
|
||||
|
||||
Dialog.alert({
|
||||
title: '情感分析',
|
||||
message: `情感倾向: ${sentiment}\n主要情感: ${emotions}\n分析: ${summary}`,
|
||||
})
|
||||
} catch (error) {
|
||||
closeToast()
|
||||
showFailToast('分析失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 构建邮件数据
|
||||
const buildMailData = () => {
|
||||
// 计算发送时间
|
||||
let sendTime
|
||||
let triggerType = 'TIME'
|
||||
let triggerCondition = {}
|
||||
|
||||
if (timeType.value === 'preset') {
|
||||
const now = new Date()
|
||||
sendTime = new Date(now)
|
||||
|
||||
switch (selectedPresetTime.value) {
|
||||
case '1day':
|
||||
sendTime.setDate(now.getDate() + 1)
|
||||
break
|
||||
case '1week':
|
||||
sendTime.setDate(now.getDate() + 7)
|
||||
break
|
||||
case '1month':
|
||||
sendTime.setMonth(now.getMonth() + 1)
|
||||
break
|
||||
case '1year':
|
||||
sendTime.setFullYear(now.getFullYear() + 1)
|
||||
break
|
||||
case '5years':
|
||||
sendTime.setFullYear(now.getFullYear() + 5)
|
||||
break
|
||||
case '10years':
|
||||
sendTime.setFullYear(now.getFullYear() + 10)
|
||||
break
|
||||
}
|
||||
} else if (timeType.value === 'custom') {
|
||||
sendTime = customDeliveryDate.value
|
||||
} else if (timeType.value === 'condition') {
|
||||
triggerType = selectedLocation.value ? 'LOCATION' : 'EVENT'
|
||||
|
||||
if (selectedLocation.value) {
|
||||
triggerCondition.location = selectedLocation.value
|
||||
}
|
||||
|
||||
if (selectedEvent.value) {
|
||||
triggerCondition.event = {
|
||||
keywords: selectedEvent.value.keywords,
|
||||
type: selectedEvent.value.type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化收件人类型
|
||||
let recipientTypeFormatted = recipientType.value
|
||||
|
||||
return {
|
||||
title: subject.value,
|
||||
content: content.value,
|
||||
recipientType: recipientTypeFormatted,
|
||||
recipientEmail: recipientType.value === 'SPECIFIC' ? recipientEmail.value : undefined,
|
||||
sendTime: sendTime ? sendTime.toISOString() : undefined,
|
||||
triggerType,
|
||||
triggerCondition,
|
||||
attachments: attachments.value,
|
||||
isEncrypted: isEncrypted.value,
|
||||
capsuleStyle: capsuleStyle.value
|
||||
}
|
||||
}
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = async () => {
|
||||
if (!subject.value) {
|
||||
showFailToast('请填写邮件标题')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
showLoadingToast({
|
||||
message: '保存中...',
|
||||
forbidClick: true,
|
||||
})
|
||||
|
||||
const mailData = buildMailData()
|
||||
await mailActions.createMail(mailData)
|
||||
|
||||
closeToast()
|
||||
showSuccessToast('草稿已保存')
|
||||
router.back()
|
||||
} catch (error) {
|
||||
closeToast()
|
||||
const errorMessage = error.response?.data?.message || '保存失败,请重试'
|
||||
showFailToast(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送邮件
|
||||
const sendMail = async () => {
|
||||
if (!subject.value) {
|
||||
showFailToast('请填写邮件标题')
|
||||
return
|
||||
}
|
||||
|
||||
if (!content.value) {
|
||||
showFailToast('请填写邮件内容')
|
||||
return
|
||||
}
|
||||
|
||||
if (recipientType.value === 'SPECIFIC' && !recipientEmail.value) {
|
||||
showFailToast('请填写收件人邮箱')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
showLoadingToast({
|
||||
message: '发送中...',
|
||||
forbidClick: true,
|
||||
})
|
||||
|
||||
const mailData = buildMailData()
|
||||
const response = await mailActions.createMail(mailData)
|
||||
|
||||
closeToast()
|
||||
|
||||
// 计算发送时间用于显示
|
||||
let deliveryDate
|
||||
if (timeType.value === 'preset' || timeType.value === 'custom') {
|
||||
deliveryDate = new Date(mailData.sendTime)
|
||||
} else {
|
||||
deliveryDate = new Date()
|
||||
deliveryDate.setFullYear(deliveryDate.getFullYear() + 1) // 默认显示一年后
|
||||
}
|
||||
|
||||
Dialog.confirm({
|
||||
title: '邮件已发送',
|
||||
message: `您的邮件将在${deliveryDate.toLocaleDateString()}送达,是否返回首页?`,
|
||||
confirmButtonText: '返回首页',
|
||||
cancelButtonText: '继续撰写',
|
||||
})
|
||||
.then(() => {
|
||||
router.push('/home')
|
||||
})
|
||||
.catch(() => {
|
||||
// 继续撰写
|
||||
})
|
||||
} catch (error) {
|
||||
closeToast()
|
||||
const errorMessage = error.response?.data?.message || '发送失败,请重试'
|
||||
showFailToast(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateStars()
|
||||
})
|
||||
|
||||
return {
|
||||
stars,
|
||||
recipientType,
|
||||
recipientEmail,
|
||||
timeType,
|
||||
selectedPresetTime,
|
||||
customDeliveryDate,
|
||||
minDate,
|
||||
presetTimeOptions,
|
||||
subject,
|
||||
content,
|
||||
showLocationPicker,
|
||||
showEventPicker,
|
||||
triggerEvents,
|
||||
areaList,
|
||||
goBack,
|
||||
selectPresetTime,
|
||||
onLocationConfirm,
|
||||
selectEvent,
|
||||
afterRead,
|
||||
generateOpening,
|
||||
generateSuggestions,
|
||||
analyzeEmotion,
|
||||
saveDraft,
|
||||
sendMail
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.compose-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
margin: 15px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.compose-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.custom-field {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.custom-field :deep(.van-field__control) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.preset-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.preset-button {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.custom-date-picker {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.media-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-uploader {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background: linear-gradient(135deg, #4a5f7a, #2c3e50);
|
||||
border: none;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 8px 20px rgba(74, 95, 122, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 25px rgba(74, 95, 122, 0.4);
|
||||
}
|
||||
|
||||
.save-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 5px 15px rgba(74, 95, 122, 0.3);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
background: linear-gradient(135deg, #00D4FF, #1D3B5A);
|
||||
border: none;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 8px 20px rgba(0, 212, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.send-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 25px rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
.send-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 5px 15px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.preset-button {
|
||||
margin: 5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 212, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.preset-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 15px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.media-uploader .van-button {
|
||||
box-shadow: 0 4px 10px rgba(0, 212, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.media-uploader .van-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 15px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.event-picker {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event-picker h3 {
|
||||
margin: 0 0 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
444
src/views/Home.vue
Normal file
444
src/views/Home.vue
Normal file
@@ -0,0 +1,444 @@
|
||||
<template>
|
||||
<div class="home-container">
|
||||
<!-- 深空背景 -->
|
||||
<div class="space-background">
|
||||
<div class="stars" ref="stars"></div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部欢迎语 -->
|
||||
<div class="header glass-card">
|
||||
<div class="welcome-text">
|
||||
<h2>欢迎回来,{{ userName }}</h2>
|
||||
<p>{{ greetingText }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<van-icon name="search" size="24" @click="showSearch = true" />
|
||||
<van-icon name="bell" size="24" @click="showNotifications = true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时光胶囊视图 -->
|
||||
<div class="capsules-container">
|
||||
<div class="capsules-space" ref="capsulesSpace">
|
||||
<!-- 时间胶囊 -->
|
||||
<div
|
||||
v-for="capsule in capsules"
|
||||
:key="capsule.id"
|
||||
class="capsule-wrapper"
|
||||
:style="getCapsuleStyle(capsule)"
|
||||
@click="openCapsule(capsule)"
|
||||
>
|
||||
<div class="time-capsule" :class="{'glowing': capsule.isGlowing}">
|
||||
<div class="capsule-info">
|
||||
<p class="capsule-title">{{ capsule.title }}</p>
|
||||
<p class="capsule-date">{{ formatDate(capsule.deliveryDate) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 悬浮按钮 -->
|
||||
<div class="fab-container">
|
||||
<van-button
|
||||
icon="plus"
|
||||
type="primary"
|
||||
round
|
||||
class="fab-button"
|
||||
@click="goToCompose"
|
||||
>
|
||||
撰写邮件
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<van-tabbar v-model="active" class="custom-tabbar">
|
||||
<van-tabbar-item icon="home-o" to="/home">时光胶囊</van-tabbar-item>
|
||||
<van-tabbar-item icon="envelop-o" to="/inbox">收件箱</van-tabbar-item>
|
||||
<van-tabbar-item icon="send-o" to="/sent">发件箱</van-tabbar-item>
|
||||
<van-tabbar-item icon="user-o" to="/profile">个人中心</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
|
||||
<!-- 搜索弹窗 -->
|
||||
<van-popup v-model:show="showSearch" position="top" :style="{ height: '30%' }">
|
||||
<div class="search-popup">
|
||||
<van-search
|
||||
v-model="searchValue"
|
||||
placeholder="搜索邮件"
|
||||
@search="onSearch"
|
||||
/>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 通知弹窗 -->
|
||||
<van-popup v-model:show="showNotifications" position="top" :style="{ height: '40%' }">
|
||||
<div class="notifications-popup">
|
||||
<h3>通知</h3>
|
||||
<div v-if="notifications.length === 0" class="empty-notifications">
|
||||
<p>暂无新通知</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="notification-item"
|
||||
>
|
||||
<p>{{ notification.message }}</p>
|
||||
<span class="notification-time">{{ formatTime(notification.time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showFailToast } from 'vant'
|
||||
import { userState, mailState, mailActions } from '../store'
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const active = ref(0)
|
||||
const stars = ref(null)
|
||||
const capsulesSpace = ref(null)
|
||||
const showSearch = ref(false)
|
||||
const showNotifications = ref(false)
|
||||
const searchValue = ref('')
|
||||
|
||||
// 使用直接导入的状态和操作
|
||||
const userName = computed(() => userState.userInfo.username || '时光旅人')
|
||||
const capsules = computed(() => mailState.sentList || [])
|
||||
const notifications = ref([]) // 暂时使用空数组,可以后续添加通知功能
|
||||
|
||||
// 根据时间获取问候语
|
||||
const greetingText = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 6) return '夜深了,注意休息'
|
||||
if (hour < 12) return '早上好,美好的一天开始了'
|
||||
if (hour < 18) return '下午好,继续加油'
|
||||
return '晚上好,今天过得怎么样'
|
||||
})
|
||||
|
||||
// 生成星空背景
|
||||
const generateStars = () => {
|
||||
if (!stars.value) return
|
||||
|
||||
const starsContainer = stars.value
|
||||
const starCount = 150
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const star = document.createElement('div')
|
||||
star.className = 'star'
|
||||
|
||||
// 随机位置
|
||||
const left = Math.random() * 100
|
||||
const top = Math.random() * 100
|
||||
|
||||
// 随机大小
|
||||
const size = Math.random() * 3 + 1
|
||||
|
||||
// 随机动画延迟
|
||||
const delay = Math.random() * 4
|
||||
|
||||
star.style.left = `${left}%`
|
||||
star.style.top = `${top}%`
|
||||
star.style.width = `${size}px`
|
||||
star.style.height = `${size}px`
|
||||
star.style.animationDelay = `${delay}s`
|
||||
|
||||
starsContainer.appendChild(star)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取胶囊样式
|
||||
const getCapsuleStyle = (capsule) => {
|
||||
return {
|
||||
left: `${capsule.position.x}%`,
|
||||
top: `${capsule.position.y}%`,
|
||||
transform: `scale(${0.5 + capsule.position.z})`,
|
||||
opacity: 0.5 + capsule.position.z * 0.5,
|
||||
zIndex: Math.floor(capsule.position.z * 10)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date) => {
|
||||
const now = new Date()
|
||||
const targetDate = new Date(date)
|
||||
const diffTime = targetDate - now
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays < 0) return '已送达'
|
||||
if (diffDays === 0) return '今天'
|
||||
if (diffDays === 1) return '明天'
|
||||
if (diffDays < 7) return `${diffDays}天后`
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)}周后`
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)}个月后`
|
||||
return `${Math.floor(diffDays / 365)}年后`
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time) => {
|
||||
const now = new Date()
|
||||
const targetDate = new Date(time)
|
||||
const diffTime = now - targetDate
|
||||
const diffMinutes = Math.floor(diffTime / (1000 * 60))
|
||||
|
||||
if (diffMinutes < 1) return '刚刚'
|
||||
if (diffMinutes < 60) return `${diffMinutes}分钟前`
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
if (diffHours < 24) return `${diffHours}小时前`
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffDays < 7) return `${diffDays}天前`
|
||||
|
||||
return targetDate.toLocaleDateString()
|
||||
}
|
||||
|
||||
// 打开胶囊详情
|
||||
const openCapsule = (capsule) => {
|
||||
router.push(`/capsule/${capsule.id}`)
|
||||
}
|
||||
|
||||
// 跳转到撰写页面
|
||||
const goToCompose = () => {
|
||||
router.push('/compose')
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const onSearch = (value) => {
|
||||
if (!value) {
|
||||
showFailToast('请输入搜索内容')
|
||||
return
|
||||
}
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(value)}`)
|
||||
showSearch.value = false
|
||||
}
|
||||
|
||||
// 获取时光胶囊数据
|
||||
const fetchCapsules = async () => {
|
||||
try {
|
||||
await mailActions.getCapsules()
|
||||
} catch (error) {
|
||||
showFailToast('获取时光胶囊数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取通知数据
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
await mailActions.getNotifications()
|
||||
} catch (error) {
|
||||
console.error('获取通知失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
generateStars()
|
||||
await fetchCapsules()
|
||||
await fetchNotifications()
|
||||
})
|
||||
|
||||
return {
|
||||
active,
|
||||
userName,
|
||||
greetingText,
|
||||
stars,
|
||||
capsulesSpace,
|
||||
capsules,
|
||||
showSearch,
|
||||
showNotifications,
|
||||
searchValue,
|
||||
notifications,
|
||||
getCapsuleStyle,
|
||||
formatDate,
|
||||
formatTime,
|
||||
openCapsule,
|
||||
goToCompose,
|
||||
onSearch
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welcome-text h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.welcome-text p {
|
||||
margin: 5px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.capsules-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.capsules-space {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.capsule-wrapper {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.capsule-wrapper:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.time-capsule {
|
||||
width: 60px;
|
||||
height: 90px;
|
||||
background: var(--gradient-color);
|
||||
border-radius: 30px;
|
||||
position: relative;
|
||||
box-shadow: 0 8px 20px rgba(0, 212, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.time-capsule::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: radial-gradient(circle, var(--accent-color), transparent);
|
||||
border-radius: 50%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.time-capsule.glowing::before {
|
||||
animation: pulse 2s infinite alternate;
|
||||
}
|
||||
|
||||
.capsule-info {
|
||||
position: absolute;
|
||||
bottom: -40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.capsule-wrapper:hover .capsule-info {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.capsule-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.capsule-date {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
transform: translateX(-50%) scale(0.8);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.fab-container {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
right: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.fab-button {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #00D4FF, #1D3B5A);
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.4);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-popup {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.notifications-popup {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notifications-popup h3 {
|
||||
margin: 0 0 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.empty-notifications {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.notification-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notification-item p {
|
||||
margin: 0 0 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
506
src/views/Inbox.vue
Normal file
506
src/views/Inbox.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<div class="inbox-container">
|
||||
<!-- 深空背景 -->
|
||||
<div class="space-background">
|
||||
<div class="stars" ref="stars"></div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<div class="header glass-card">
|
||||
<van-icon name="arrow-left" size="24" @click="goBack" />
|
||||
<h2>收件箱</h2>
|
||||
<van-icon name="filter-o" size="24" @click="showFilter = true" />
|
||||
</div>
|
||||
|
||||
<!-- 分段控件 -->
|
||||
<div class="tab-container">
|
||||
<van-tabs v-model:active="activeTab" class="custom-tabs">
|
||||
<van-tab title="已送达">
|
||||
<div class="mail-list">
|
||||
<div
|
||||
v-for="mail in deliveredMails"
|
||||
:key="mail.mailId"
|
||||
class="mail-item glass-card"
|
||||
@click="openMail(mail)"
|
||||
>
|
||||
<div class="mail-icon">
|
||||
<van-icon name="envelop" size="24" />
|
||||
</div>
|
||||
<div class="mail-content">
|
||||
<div class="mail-header">
|
||||
<h3 class="mail-title">{{ mail.title }}</h3>
|
||||
<span class="mail-date">{{ formatDate(mail.deliveryTime) }}</span>
|
||||
</div>
|
||||
<p class="mail-preview">{{ mail.content.substring(0, 50) }}...</p>
|
||||
<div class="mail-footer">
|
||||
<span class="mail-sender">来自: {{ mail.sender.username }}</span>
|
||||
<van-tag type="primary" size="small">已开启</van-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="deliveredMails.length === 0" class="empty-state">
|
||||
<van-empty description="暂无已送达的邮件" />
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="在路上">
|
||||
<div class="mail-list">
|
||||
<div
|
||||
v-for="mail in incomingMails"
|
||||
:key="mail.mailId"
|
||||
class="mail-item glass-card"
|
||||
@click="openMail(mail)"
|
||||
>
|
||||
<div class="mail-icon">
|
||||
<div class="time-capsule-small"></div>
|
||||
</div>
|
||||
<div class="mail-content">
|
||||
<div class="mail-header">
|
||||
<h3 class="mail-title">{{ mail.title }}</h3>
|
||||
<span class="mail-date">{{ formatDate(mail.sendTime) }}</span>
|
||||
</div>
|
||||
<p class="mail-preview">{{ mail.content.substring(0, 50) }}...</p>
|
||||
<div class="mail-footer">
|
||||
<span class="mail-sender">来自: {{ mail.sender.username }}</span>
|
||||
<div class="countdown" v-if="mail.countdown">
|
||||
<van-count-down :time="mail.countdown * 1000" format="DD 天 HH 时 mm 分" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="incomingMails.length === 0" class="empty-state">
|
||||
<van-empty description="暂无在路上的邮件" />
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<van-tabbar v-model="active" class="custom-tabbar">
|
||||
<van-tabbar-item icon="home-o" to="/home">时光胶囊</van-tabbar-item>
|
||||
<van-tabbar-item icon="envelop-o" to="/inbox">收件箱</van-tabbar-item>
|
||||
<van-tabbar-item icon="send-o" to="/sent">发件箱</van-tabbar-item>
|
||||
<van-tabbar-item icon="user-o" to="/profile">个人中心</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
|
||||
<!-- 筛选弹窗 -->
|
||||
<van-popup v-model:show="showFilter" position="bottom" :style="{ height: '40%' }">
|
||||
<div class="filter-popup">
|
||||
<h3>筛选邮件</h3>
|
||||
<van-cell-group>
|
||||
<van-cell title="发件人" is-link @click="showSenderPicker = true" />
|
||||
<van-cell title="时间范围" is-link @click="showDateRangePicker = true" />
|
||||
<van-cell title="邮件类型" is-link @click="showTypePicker = true" />
|
||||
</van-cell-group>
|
||||
<div class="filter-actions">
|
||||
<van-button block @click="resetFilter">重置</van-button>
|
||||
<van-button block type="primary" @click="applyFilter">应用</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 发件人选择弹窗 -->
|
||||
<van-popup v-model:show="showSenderPicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="senderOptions"
|
||||
@confirm="onSenderConfirm"
|
||||
@cancel="showSenderPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 时间范围选择弹窗 -->
|
||||
<van-popup v-model:show="showDateRangePicker" position="bottom">
|
||||
<van-calendar v-model="showDateRangePicker" type="range" @confirm="onDateRangeConfirm" />
|
||||
</van-popup>
|
||||
|
||||
<!-- 邮件类型选择弹窗 -->
|
||||
<van-popup v-model:show="showTypePicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="typeOptions"
|
||||
@confirm="onTypeConfirm"
|
||||
@cancel="showTypePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showFailToast } from 'vant'
|
||||
import { mailActions, mailState } from '../store'
|
||||
|
||||
export default {
|
||||
name: 'Inbox',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const active = ref(1)
|
||||
const activeTab = ref(0)
|
||||
const stars = ref(null)
|
||||
|
||||
// 使用直接导入的状态和操作
|
||||
const mails = computed(() => mailState.inboxList || [])
|
||||
const loading = computed(() => mailState.loading)
|
||||
|
||||
// 弹窗控制
|
||||
const showFilter = ref(false)
|
||||
const showSenderPicker = ref(false)
|
||||
const showDateRangePicker = ref(false)
|
||||
const showTypePicker = ref(false)
|
||||
|
||||
// 筛选条件
|
||||
const selectedSender = ref('')
|
||||
const selectedDateRange = ref([])
|
||||
const selectedType = ref('')
|
||||
|
||||
// 分页参数
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const hasMore = ref(true)
|
||||
|
||||
// 根据状态筛选邮件
|
||||
const deliveredMails = computed(() => {
|
||||
return mails.value.filter(mail => mail.status === 'DELIVERED')
|
||||
})
|
||||
|
||||
const incomingMails = computed(() => {
|
||||
return mails.value.filter(mail => mail.status === 'PENDING' || mail.status === 'DELIVERING')
|
||||
})
|
||||
|
||||
// 筛选选项
|
||||
const senderOptions = ['全部', '过去的我', '朋友', '家人', '恋人']
|
||||
const typeOptions = ['全部', '给自己', '给他人', '公开信']
|
||||
|
||||
// 获取邮件列表
|
||||
const fetchMails = async (reset = false) => {
|
||||
try {
|
||||
if (reset) {
|
||||
currentPage.value = 1
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
if (!hasMore.value) return
|
||||
|
||||
const params = {
|
||||
type: 'INBOX',
|
||||
page: currentPage.value,
|
||||
size: pageSize.value
|
||||
}
|
||||
|
||||
await mailActions.getMails(params)
|
||||
|
||||
// 检查是否还有更多数据
|
||||
if (mails.value.length < currentPage.value * pageSize.value) {
|
||||
hasMore.value = false
|
||||
} else {
|
||||
currentPage.value++
|
||||
}
|
||||
} catch (error) {
|
||||
showFailToast('获取邮件列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
const loadMore = () => {
|
||||
if (!loading.value && hasMore.value) {
|
||||
fetchMails()
|
||||
}
|
||||
}
|
||||
|
||||
// 生成星空背景
|
||||
const generateStars = () => {
|
||||
if (!stars.value) return
|
||||
|
||||
const starsContainer = stars.value
|
||||
const starCount = 100
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const star = document.createElement('div')
|
||||
star.className = 'star'
|
||||
|
||||
// 随机位置
|
||||
const left = Math.random() * 100
|
||||
const top = Math.random() * 100
|
||||
|
||||
// 随机大小
|
||||
const size = Math.random() * 3 + 1
|
||||
|
||||
// 随机动画延迟
|
||||
const delay = Math.random() * 4
|
||||
|
||||
star.style.left = `${left}%`
|
||||
star.style.top = `${top}%`
|
||||
star.style.width = `${size}px`
|
||||
star.style.height = `${size}px`
|
||||
star.style.animationDelay = `${delay}s`
|
||||
|
||||
starsContainer.appendChild(star)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffTime = date - now
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays < 0) {
|
||||
// 过去的时间
|
||||
const pastDays = Math.abs(diffDays)
|
||||
if (pastDays === 0) return '今天'
|
||||
if (pastDays === 1) return '昨天'
|
||||
if (pastDays < 7) return `${pastDays}天前`
|
||||
if (pastDays < 30) return `${Math.floor(pastDays / 7)}周前`
|
||||
if (pastDays < 365) return `${Math.floor(pastDays / 30)}个月前`
|
||||
return `${Math.floor(pastDays / 365)}年前`
|
||||
} else {
|
||||
// 未来的时间
|
||||
if (diffDays === 0) return '今天'
|
||||
if (diffDays === 1) return '明天'
|
||||
if (diffDays < 7) return `${diffDays}天后`
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)}周后`
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)}个月后`
|
||||
return `${Math.floor(diffDays / 365)}年后`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取倒计时时间
|
||||
const getCountdownTime = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.getTime() - new Date().getTime()
|
||||
}
|
||||
|
||||
// 打开邮件
|
||||
const openMail = (mail) => {
|
||||
router.push(`/capsule/${mail.mailId}`)
|
||||
}
|
||||
|
||||
// 发件人选择确认
|
||||
const onSenderConfirm = (value) => {
|
||||
selectedSender.value = value
|
||||
showSenderPicker.value = false
|
||||
}
|
||||
|
||||
// 时间范围选择确认
|
||||
const onDateRangeConfirm = (value) => {
|
||||
selectedDateRange.value = value
|
||||
showDateRangePicker.value = false
|
||||
}
|
||||
|
||||
// 邮件类型选择确认
|
||||
const onTypeConfirm = (value) => {
|
||||
selectedType.value = value
|
||||
showTypePicker.value = false
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
const resetFilter = () => {
|
||||
selectedSender.value = ''
|
||||
selectedDateRange.value = []
|
||||
selectedType.value = ''
|
||||
fetchMails(true) // 重新获取数据
|
||||
showFailToast('筛选条件已重置')
|
||||
}
|
||||
|
||||
// 应用筛选
|
||||
const applyFilter = () => {
|
||||
showFilter.value = false
|
||||
fetchMails(true) // 重新获取数据
|
||||
showFailToast('筛选条件已应用')
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refresh = () => {
|
||||
fetchMails(true)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateStars()
|
||||
fetchMails(true)
|
||||
})
|
||||
|
||||
return {
|
||||
active,
|
||||
activeTab,
|
||||
stars,
|
||||
deliveredMails,
|
||||
incomingMails,
|
||||
loading,
|
||||
showFilter,
|
||||
showSenderPicker,
|
||||
showDateRangePicker,
|
||||
showTypePicker,
|
||||
senderOptions,
|
||||
typeOptions,
|
||||
goBack,
|
||||
formatDate,
|
||||
getCountdownTime,
|
||||
openMail,
|
||||
onSenderConfirm,
|
||||
onDateRangeConfirm,
|
||||
onTypeConfirm,
|
||||
resetFilter,
|
||||
applyFilter,
|
||||
loadMore,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inbox-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.custom-tabs :deep(.van-tabs__content) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mail-list {
|
||||
padding: 0 15px 15px;
|
||||
}
|
||||
|
||||
.mail-item {
|
||||
display: flex;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mail-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.mail-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.time-capsule-small {
|
||||
width: 30px;
|
||||
height: 45px;
|
||||
background: var(--gradient-color);
|
||||
border-radius: 15px;
|
||||
position: relative;
|
||||
box-shadow: 0 5px 15px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.time-capsule-small::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: radial-gradient(circle, var(--accent-color), transparent);
|
||||
border-radius: 50%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mail-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.mail-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mail-date {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mail-preview {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.mail-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mail-sender {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-size: 12px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.filter-popup {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filter-popup h3 {
|
||||
margin: 0 0 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
219
src/views/Login.vue
Normal file
219
src/views/Login.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 深空背景 -->
|
||||
<div class="space-background">
|
||||
<div class="stars" ref="stars"></div>
|
||||
</div>
|
||||
|
||||
<div class="login-content">
|
||||
<!-- Logo和标题 -->
|
||||
<div class="logo-section">
|
||||
<div class="logo">
|
||||
<div class="time-capsule"></div>
|
||||
</div>
|
||||
<h1 class="app-title">ChronoMail</h1>
|
||||
<p class="app-slogan">写给未来,不负当下</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<div class="login-form glass-card p-30">
|
||||
<van-form @submit="onSubmit">
|
||||
<van-field
|
||||
v-model="email"
|
||||
name="email"
|
||||
placeholder="邮箱"
|
||||
:rules="[{ required: true, message: '请填写邮箱' }]"
|
||||
class="custom-field"
|
||||
/>
|
||||
<van-field
|
||||
v-model="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="密码"
|
||||
:rules="[{ required: true, message: '请填写密码' }]"
|
||||
class="custom-field"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<van-button round block type="primary" native-type="submit" class="login-button">
|
||||
登录
|
||||
</van-button>
|
||||
<div class="register-link mt-20">
|
||||
还没有账号?<span @click="goToRegister">立即注册</span>
|
||||
</div>
|
||||
</div>
|
||||
</van-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showLoadingToast, showSuccessToast, showFailToast, closeToast } from 'vant'
|
||||
import { userActions } from '../store'
|
||||
|
||||
export default {
|
||||
name: 'Login',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const stars = ref(null)
|
||||
|
||||
// 生成星空背景
|
||||
const generateStars = () => {
|
||||
if (!stars.value) return
|
||||
|
||||
const starsContainer = stars.value
|
||||
const starCount = 100
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const star = document.createElement('div')
|
||||
star.className = 'star'
|
||||
|
||||
// 随机位置
|
||||
const left = Math.random() * 100
|
||||
const top = Math.random() * 100
|
||||
|
||||
// 随机大小
|
||||
const size = Math.random() * 3 + 1
|
||||
|
||||
// 随机动画延迟
|
||||
const delay = Math.random() * 4
|
||||
|
||||
star.style.left = `${left}%`
|
||||
star.style.top = `${top}%`
|
||||
star.style.width = `${size}px`
|
||||
star.style.height = `${size}px`
|
||||
star.style.animationDelay = `${delay}s`
|
||||
|
||||
starsContainer.appendChild(star)
|
||||
}
|
||||
}
|
||||
|
||||
// 登录处理
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
showLoadingToast({
|
||||
message: '登录中...',
|
||||
forbidClick: true,
|
||||
})
|
||||
|
||||
// 调用API进行登录验证
|
||||
await userActions.login({
|
||||
usernameOrEmail: email.value,
|
||||
password: password.value
|
||||
})
|
||||
|
||||
closeToast()
|
||||
showSuccessToast('登录成功')
|
||||
|
||||
// 跳转到首页
|
||||
router.push('/home')
|
||||
} catch (error) {
|
||||
closeToast()
|
||||
const errorMessage = error.response?.data?.message || '登录失败,请检查用户名和密码'
|
||||
showFailToast(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到注册页
|
||||
const goToRegister = () => {
|
||||
router.push('/register')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateStars()
|
||||
})
|
||||
|
||||
return {
|
||||
email,
|
||||
password,
|
||||
stars,
|
||||
onSubmit,
|
||||
goToRegister
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(135deg, #00D4FF, #ffffff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-slogan {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
background: linear-gradient(135deg, #00D4FF, #1D3B5A);
|
||||
border: none;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0 8px 20px rgba(0, 212, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 25px rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 5px 15px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.register-link span {
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
512
src/views/Profile.vue
Normal file
512
src/views/Profile.vue
Normal file
@@ -0,0 +1,512 @@
|
||||
<template>
|
||||
<div class="profile-container">
|
||||
<!-- 深空背景 -->
|
||||
<div class="space-background">
|
||||
<div class="stars" ref="stars"></div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<div class="header glass-card">
|
||||
<van-icon name="arrow-left" size="24" @click="goBack" />
|
||||
<h2>个人中心</h2>
|
||||
<van-icon name="setting-o" size="24" @click="goToSettings" />
|
||||
</div>
|
||||
|
||||
<!-- 用户信息卡片 -->
|
||||
<div class="user-card glass-card p-20">
|
||||
<div class="user-avatar">
|
||||
<img :src="userAvatar" alt="用户头像" />
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<h3>{{ userName }}</h3>
|
||||
<p>{{ userEmail }}</p>
|
||||
<p class="user-motto">{{ userMotto }}</p>
|
||||
</div>
|
||||
<van-icon name="edit" size="20" @click="editProfile" />
|
||||
</div>
|
||||
|
||||
<!-- 核心数据卡片 -->
|
||||
<div class="stats-container">
|
||||
<div class="stats-card glass-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ sentCount }}</div>
|
||||
<div class="stat-label">寄出的信</div>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ receivedCount }}</div>
|
||||
<div class="stat-label">收到的信</div>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ totalDays }}</div>
|
||||
<div class="stat-label">时间旅行(天)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 功能列表 -->
|
||||
<div class="function-list">
|
||||
<van-cell-group class="glass-card">
|
||||
<van-cell
|
||||
title="我的时间线"
|
||||
icon="chart-trending-o"
|
||||
is-link
|
||||
@click="goToTimeline"
|
||||
/>
|
||||
<van-cell
|
||||
title="数据统计"
|
||||
icon="bar-chart-o"
|
||||
is-link
|
||||
@click="goToStats"
|
||||
/>
|
||||
<van-cell
|
||||
title="AI助手"
|
||||
icon="bulb-o"
|
||||
is-link
|
||||
@click="goToAIAssistant"
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<van-cell-group class="glass-card mt-20">
|
||||
<van-cell
|
||||
title="数据备份"
|
||||
icon="folder-o"
|
||||
is-link
|
||||
@click="goToBackup"
|
||||
/>
|
||||
<van-cell
|
||||
title="隐私设置"
|
||||
icon="shield-o"
|
||||
is-link
|
||||
@click="goToPrivacy"
|
||||
/>
|
||||
<van-cell
|
||||
title="通知管理"
|
||||
icon="bell"
|
||||
is-link
|
||||
@click="goToNotifications"
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<van-cell-group class="glass-card mt-20">
|
||||
<van-cell
|
||||
title="帮助与反馈"
|
||||
icon="question-o"
|
||||
is-link
|
||||
@click="goToHelp"
|
||||
/>
|
||||
<van-cell
|
||||
title="关于ChronoMail"
|
||||
icon="info-o"
|
||||
is-link
|
||||
@click="showAbout"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 退出登录按钮 -->
|
||||
<div class="logout-container">
|
||||
<van-button round block class="logout-button" @click="logout">
|
||||
退出登录
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<van-tabbar v-model="active" class="custom-tabbar">
|
||||
<van-tabbar-item icon="home-o" to="/home">时光胶囊</van-tabbar-item>
|
||||
<van-tabbar-item icon="envelop-o" to="/inbox">收件箱</van-tabbar-item>
|
||||
<van-tabbar-item icon="send-o" to="/sent">发件箱</van-tabbar-item>
|
||||
<van-tabbar-item icon="user-o" to="/profile">个人中心</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
|
||||
<!-- 关于弹窗 -->
|
||||
<van-popup v-model:show="showAboutPopup" position="bottom" :style="{ height: '40%' }">
|
||||
<div class="about-popup">
|
||||
<div class="about-logo">
|
||||
<div class="time-capsule"></div>
|
||||
<h3>ChronoMail</h3>
|
||||
<p>版本 1.0.0</p>
|
||||
</div>
|
||||
<div class="about-content">
|
||||
<p>ChronoMail 是一款可以发送邮件到未来的应用,帮助您与未来的自己进行对话,记录成长与蜕变。</p>
|
||||
<p class="about-slogan">"写给未来,不负当下"</p>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showFailToast, showSuccessToast, Dialog } from 'vant'
|
||||
import { userActions, mailActions, userState } from '../store'
|
||||
|
||||
export default {
|
||||
name: 'Profile',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const active = ref(3)
|
||||
const stars = ref(null)
|
||||
const showAboutPopup = ref(false)
|
||||
|
||||
// 使用直接导入的状态和操作
|
||||
const user = computed(() => userState.userInfo)
|
||||
|
||||
// 用户信息
|
||||
const userName = ref(user.value?.username || '时光旅人')
|
||||
const userEmail = ref(user.value?.email || 'traveler@chronomail.com')
|
||||
const userMotto = ref('穿越时空,与未来的自己对话')
|
||||
const userAvatar = ref(user.value?.avatar || 'https://picsum.photos/seed/user123/100/100.jpg')
|
||||
|
||||
// 统计数据
|
||||
const sentCount = ref(0)
|
||||
const receivedCount = ref(0)
|
||||
const totalDays = ref(0)
|
||||
|
||||
// 获取用户统计数据
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
const response = await mailActions.getStatistics()
|
||||
const data = response.data
|
||||
|
||||
sentCount.value = data.totalSent || 0
|
||||
receivedCount.value = data.totalReceived || 0
|
||||
totalDays.value = data.timeTravelDuration || 0
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const fetchUserProfile = async () => {
|
||||
try {
|
||||
const response = await userActions.getProfile()
|
||||
const userData = response.data
|
||||
|
||||
userName.value = userData.username || '时光旅人'
|
||||
userEmail.value = userData.email || 'traveler@chronomail.com'
|
||||
userAvatar.value = userData.avatar || 'https://picsum.photos/seed/user123/100/100.jpg'
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成星空背景
|
||||
const generateStars = () => {
|
||||
if (!stars.value) return
|
||||
|
||||
const starsContainer = stars.value
|
||||
const starCount = 100
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const star = document.createElement('div')
|
||||
star.className = 'star'
|
||||
|
||||
// 随机位置
|
||||
const left = Math.random() * 100
|
||||
const top = Math.random() * 100
|
||||
|
||||
// 随机大小
|
||||
const size = Math.random() * 3 + 1
|
||||
|
||||
// 随机动画延迟
|
||||
const delay = Math.random() * 4
|
||||
|
||||
star.style.left = `${left}%`
|
||||
star.style.top = `${top}%`
|
||||
star.style.width = `${size}px`
|
||||
star.style.height = `${size}px`
|
||||
star.style.animationDelay = `${delay}s`
|
||||
|
||||
starsContainer.appendChild(star)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 跳转到设置页面
|
||||
const goToSettings = () => {
|
||||
showFailToast('设置页面开发中')
|
||||
}
|
||||
|
||||
// 编辑个人资料
|
||||
const editProfile = () => {
|
||||
showFailToast('编辑个人资料功能开发中')
|
||||
}
|
||||
|
||||
// 跳转到时间线
|
||||
const goToTimeline = () => {
|
||||
router.push('/timeline')
|
||||
}
|
||||
|
||||
// 跳转到数据统计
|
||||
const goToStats = () => {
|
||||
showFailToast('数据统计功能开发中')
|
||||
}
|
||||
|
||||
// 跳转到AI助手
|
||||
const goToAIAssistant = () => {
|
||||
showFailToast('AI助手功能开发中')
|
||||
}
|
||||
|
||||
// 跳转到数据备份
|
||||
const goToBackup = () => {
|
||||
showFailToast('数据备份功能开发中')
|
||||
}
|
||||
|
||||
// 跳转到隐私设置
|
||||
const goToPrivacy = () => {
|
||||
showFailToast('隐私设置功能开发中')
|
||||
}
|
||||
|
||||
// 跳转到通知管理
|
||||
const goToNotifications = () => {
|
||||
showFailToast('通知管理功能开发中')
|
||||
}
|
||||
|
||||
// 跳转到帮助与反馈
|
||||
const goToHelp = () => {
|
||||
showFailToast('帮助与反馈功能开发中')
|
||||
}
|
||||
|
||||
// 显示关于弹窗
|
||||
const showAbout = () => {
|
||||
showAboutPopup.value = true
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const logout = () => {
|
||||
Dialog.confirm({
|
||||
title: '确认退出',
|
||||
message: '确定要退出登录吗?',
|
||||
})
|
||||
.then(() => {
|
||||
// 清除登录状态
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
|
||||
showSuccessToast('已退出登录')
|
||||
|
||||
// 跳转到登录页
|
||||
router.push('/login')
|
||||
})
|
||||
.catch(() => {
|
||||
// 取消操作
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateStars()
|
||||
fetchUserProfile()
|
||||
fetchStatistics()
|
||||
|
||||
// 获取用户信息
|
||||
const userStr = localStorage.getItem('user')
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr)
|
||||
userName.value = user.name || '时光旅人'
|
||||
userEmail.value = user.email || 'traveler@chronomail.com'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
active,
|
||||
stars,
|
||||
userName,
|
||||
userEmail,
|
||||
userMotto,
|
||||
userAvatar,
|
||||
sentCount,
|
||||
receivedCount,
|
||||
totalDays,
|
||||
showAboutPopup,
|
||||
goBack,
|
||||
goToSettings,
|
||||
editProfile,
|
||||
goToTimeline,
|
||||
goToStats,
|
||||
goToAIAssistant,
|
||||
goToBackup,
|
||||
goToPrivacy,
|
||||
goToNotifications,
|
||||
goToHelp,
|
||||
showAbout,
|
||||
logout
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 15px 20px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-info h3 {
|
||||
margin: 0 0 5px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-info p {
|
||||
margin: 0 0 5px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.user-motto {
|
||||
font-style: italic;
|
||||
color: var(--accent-color) !important;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
margin: 0 15px 20px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.function-list {
|
||||
margin: 0 15px 20px;
|
||||
}
|
||||
|
||||
.function-list .van-cell-group {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.function-list .van-cell {
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.function-list .van-cell::after {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.logout-container {
|
||||
margin: 0 15px 20px;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: linear-gradient(135deg, #ff4d4f, #c41e3a);
|
||||
border: none;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0 8px 20px rgba(255, 77, 79, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 25px rgba(255, 77, 79, 0.4);
|
||||
}
|
||||
|
||||
.logout-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 5px 15px rgba(255, 77, 79, 0.3);
|
||||
}
|
||||
|
||||
.about-popup {
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-logo {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.about-logo h3 {
|
||||
margin: 10px 0 5px;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
background: linear-gradient(135deg, #00D4FF, #ffffff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.about-logo p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.about-content {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.about-content p {
|
||||
margin: 10px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.about-slogan {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: var(--accent-color);
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
257
src/views/Register.vue
Normal file
257
src/views/Register.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div class="register-container">
|
||||
<!-- 深空背景 -->
|
||||
<div class="space-background">
|
||||
<div class="stars" ref="stars"></div>
|
||||
</div>
|
||||
|
||||
<div class="register-content">
|
||||
<!-- Logo和标题 -->
|
||||
<div class="logo-section">
|
||||
<div class="logo">
|
||||
<div class="time-capsule"></div>
|
||||
</div>
|
||||
<h1 class="app-title">加入 ChronoMail</h1>
|
||||
<p class="app-slogan">开始你的时间之旅</p>
|
||||
</div>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<div class="register-form glass-card p-30">
|
||||
<van-form @submit="onSubmit">
|
||||
<van-field
|
||||
v-model="username"
|
||||
name="username"
|
||||
placeholder="用户名"
|
||||
:rules="[{ required: true, message: '请填写用户名' }]"
|
||||
class="custom-field"
|
||||
/>
|
||||
<van-field
|
||||
v-model="email"
|
||||
name="email"
|
||||
placeholder="邮箱"
|
||||
:rules="[
|
||||
{ required: true, message: '请填写邮箱' },
|
||||
{ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '请输入正确的邮箱格式' }
|
||||
]"
|
||||
class="custom-field"
|
||||
/>
|
||||
<van-field
|
||||
v-model="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="密码"
|
||||
:rules="[
|
||||
{ required: true, message: '请填写密码' },
|
||||
{ pattern: /^(?=.*[a-zA-Z])(?=.*\d).{6,}$/, message: '密码至少6位,包含字母和数字' }
|
||||
]"
|
||||
class="custom-field"
|
||||
/>
|
||||
<van-field
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="确认密码"
|
||||
:rules="[
|
||||
{ required: true, message: '请确认密码' },
|
||||
{ validator: validatePassword }
|
||||
]"
|
||||
class="custom-field"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<van-button round block type="primary" native-type="submit" class="register-button">
|
||||
注册
|
||||
</van-button>
|
||||
<div class="login-link mt-20">
|
||||
已有账号?<span @click="goToLogin">立即登录</span>
|
||||
</div>
|
||||
</div>
|
||||
</van-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showLoadingToast, showSuccessToast, showFailToast, closeToast } from 'vant'
|
||||
import { userActions } from '../store'
|
||||
|
||||
export default {
|
||||
name: 'Register',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const stars = ref(null)
|
||||
|
||||
// 生成星空背景
|
||||
const generateStars = () => {
|
||||
if (!stars.value) return
|
||||
|
||||
const starsContainer = stars.value
|
||||
const starCount = 100
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const star = document.createElement('div')
|
||||
star.className = 'star'
|
||||
|
||||
// 随机位置
|
||||
const left = Math.random() * 100
|
||||
const top = Math.random() * 100
|
||||
|
||||
// 随机大小
|
||||
const size = Math.random() * 3 + 1
|
||||
|
||||
// 随机动画延迟
|
||||
const delay = Math.random() * 4
|
||||
|
||||
star.style.left = `${left}%`
|
||||
star.style.top = `${top}%`
|
||||
star.style.width = `${size}px`
|
||||
star.style.height = `${size}px`
|
||||
star.style.animationDelay = `${delay}s`
|
||||
|
||||
starsContainer.appendChild(star)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const validatePassword = () => {
|
||||
if (password.value !== confirmPassword.value) {
|
||||
return '两次输入的密码不一致'
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 注册处理
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
showLoadingToast({
|
||||
message: '注册中...',
|
||||
forbidClick: true,
|
||||
})
|
||||
|
||||
// 调用API进行注册
|
||||
await userActions.register({
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
password: password.value
|
||||
})
|
||||
|
||||
closeToast()
|
||||
showSuccessToast('注册成功')
|
||||
|
||||
// 跳转到登录页
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
closeToast()
|
||||
const errorMessage = error.response?.data?.message || '注册失败,请稍后再试'
|
||||
showFailToast(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到登录页
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateStars()
|
||||
})
|
||||
|
||||
return {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
stars,
|
||||
validatePassword,
|
||||
onSubmit,
|
||||
goToLogin
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.register-content {
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(135deg, #00D4FF, #ffffff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-slogan {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.register-form {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.register-button {
|
||||
background: linear-gradient(135deg, #00D4FF, #1D3B5A);
|
||||
border: none;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0 8px 20px rgba(0, 212, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.register-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 25px rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
.register-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 5px 15px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.login-link span {
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
617
src/views/Sent.vue
Normal file
617
src/views/Sent.vue
Normal file
@@ -0,0 +1,617 @@
|
||||
<template>
|
||||
<div class="sent-container">
|
||||
<!-- 深空背景 -->
|
||||
<div class="space-background">
|
||||
<div class="stars" ref="stars"></div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<div class="header glass-card">
|
||||
<van-icon name="arrow-left" size="24" @click="goBack" />
|
||||
<h2>发件箱</h2>
|
||||
<van-icon name="sort" size="24" @click="showSort = true" />
|
||||
</div>
|
||||
|
||||
<!-- 邮件列表 -->
|
||||
<div class="mail-list">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<div
|
||||
v-for="mail in sortedMails"
|
||||
:key="mail.mailId"
|
||||
class="mail-item glass-card"
|
||||
@click="openMail(mail)"
|
||||
>
|
||||
<div class="mail-icon">
|
||||
<div class="time-capsule-small" :class="{'delivered': mail.status === 'DELIVERED'}"></div>
|
||||
</div>
|
||||
<div class="mail-content">
|
||||
<div class="mail-header">
|
||||
<h3 class="mail-title">{{ mail.title }}</h3>
|
||||
<span class="mail-date">{{ formatDate(mail.sendTime) }}</span>
|
||||
</div>
|
||||
<p class="mail-preview">{{ mail.content.substring(0, 50) }}...</p>
|
||||
<div class="mail-footer">
|
||||
<span class="mail-recipient">收件人: {{ mail.recipient.username }}</span>
|
||||
<van-tag :type="getStatusType(mail.status)" size="small">
|
||||
{{ getStatusText(mail.status) }}
|
||||
</van-tag>
|
||||
</div>
|
||||
<div class="mail-progress">
|
||||
<div class="progress-info">
|
||||
<span>投递进度</span>
|
||||
<span>{{ getProgressText(mail) }}</span>
|
||||
</div>
|
||||
<van-progress
|
||||
:percentage="getProgressPercentage(mail)"
|
||||
stroke-width="4"
|
||||
color="#00D4FF"
|
||||
track-color="rgba(255, 255, 255, 0.1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mail-actions">
|
||||
<van-icon name="eye" size="18" @click.stop="previewMail(mail)" />
|
||||
<van-icon name="edit" size="18" @click.stop="editMail(mail)" v-if="mail.status === 'DRAFT'" />
|
||||
<van-icon name="close" size="18" @click.stop="cancelMail(mail)" v-if="mail.status === 'PENDING'" />
|
||||
</div>
|
||||
</div>
|
||||
</van-list>
|
||||
|
||||
<div v-if="mails.value.length === 0 && !loading" class="empty-state">
|
||||
<van-empty description="暂无已发送的邮件" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<van-tabbar v-model="active" class="custom-tabbar">
|
||||
<van-tabbar-item icon="home-o" to="/home">时光胶囊</van-tabbar-item>
|
||||
<van-tabbar-item icon="envelop-o" to="/inbox">收件箱</van-tabbar-item>
|
||||
<van-tabbar-item icon="send-o" to="/sent">发件箱</van-tabbar-item>
|
||||
<van-tabbar-item icon="user-o" to="/profile">个人中心</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
|
||||
<!-- 排序弹窗 -->
|
||||
<van-popup v-model:show="showSort" position="bottom" :style="{ height: '30%' }">
|
||||
<div class="sort-popup">
|
||||
<h3>排序方式</h3>
|
||||
<van-radio-group v-model="sortType">
|
||||
<van-cell-group>
|
||||
<van-cell title="按发送时间" clickable @click="sortType = 'sendDate'">
|
||||
<template #right-icon>
|
||||
<van-radio name="sendDate" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="按投递时间" clickable @click="sortType = 'deliveryDate'">
|
||||
<template #right-icon>
|
||||
<van-radio name="deliveryDate" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="按状态" clickable @click="sortType = 'status'">
|
||||
<template #right-icon>
|
||||
<van-radio name="status" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</van-radio-group>
|
||||
<div class="sort-actions">
|
||||
<van-button block type="primary" @click="applySort">确定</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<van-popup v-model:show="showPreview" position="bottom" :style="{ height: '70%' }">
|
||||
<div class="preview-popup">
|
||||
<div class="preview-header">
|
||||
<h3>{{ previewMailData.title }}</h3>
|
||||
<van-icon name="cross" @click="showPreview = false" />
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<div class="preview-info">
|
||||
<p><strong>收件人:</strong> {{ previewMailData.recipient?.username || '未知' }}</p>
|
||||
<p><strong>发送时间:</strong> {{ formatDate(previewMailData.sendTime) }}</p>
|
||||
<p v-if="previewMailData.deliveryTime"><strong>投递时间:</strong> {{ formatDate(previewMailData.deliveryTime) }}</p>
|
||||
<p><strong>状态:</strong> {{ getStatusText(previewMailData.status) }}</p>
|
||||
</div>
|
||||
<div class="preview-text">
|
||||
{{ previewMailData.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showFailToast, showSuccessToast, Dialog } from 'vant'
|
||||
import { mailActions, mailState } from '../store'
|
||||
|
||||
export default {
|
||||
name: 'Sent',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const active = ref(2)
|
||||
const stars = ref(null)
|
||||
const sortType = ref('sendDate')
|
||||
const showSort = ref(false)
|
||||
const showPreview = ref(false)
|
||||
const previewMailData = ref(null)
|
||||
|
||||
// 使用直接导入的状态和操作
|
||||
const mails = computed(() => mailState.sentList)
|
||||
const loading = computed(() => mailState.loading)
|
||||
|
||||
// 邮件数据
|
||||
const finished = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
// 排序后的邮件
|
||||
const sortedMails = computed(() => {
|
||||
const sorted = [...mails.value]
|
||||
|
||||
switch (sortType.value) {
|
||||
case 'sendDate':
|
||||
return sorted.sort((a, b) => new Date(b.sendTime) - new Date(a.sendTime))
|
||||
case 'deliveryDate':
|
||||
return sorted.sort((a, b) => {
|
||||
if (!a.deliveryTime) return 1
|
||||
if (!b.deliveryTime) return -1
|
||||
return new Date(a.deliveryTime) - new Date(b.deliveryTime)
|
||||
})
|
||||
case 'status':
|
||||
const statusOrder = { DRAFT: 0, PENDING: 1, DELIVERING: 2, DELIVERED: 3 }
|
||||
return sorted.sort((a, b) => statusOrder[a.status] - statusOrder[b.status])
|
||||
default:
|
||||
return sorted
|
||||
}
|
||||
})
|
||||
|
||||
// 生成星空背景
|
||||
const generateStars = () => {
|
||||
if (!stars.value) return
|
||||
|
||||
const starsContainer = stars.value
|
||||
const starCount = 100
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const star = document.createElement('div')
|
||||
star.className = 'star'
|
||||
|
||||
// 随机位置
|
||||
const left = Math.random() * 100
|
||||
const top = Math.random() * 100
|
||||
|
||||
// 随机大小
|
||||
const size = Math.random() * 3 + 1
|
||||
|
||||
// 随机动画延迟
|
||||
const delay = Math.random() * 4
|
||||
|
||||
star.style.left = `${left}%`
|
||||
star.style.top = `${top}%`
|
||||
star.style.width = `${size}px`
|
||||
star.style.height = `${size}px`
|
||||
star.style.animationDelay = `${delay}s`
|
||||
|
||||
starsContainer.appendChild(star)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取已发送邮件列表
|
||||
const fetchMails = async (reset = false) => {
|
||||
if (loading.value || finished.value) return
|
||||
|
||||
try {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
}
|
||||
|
||||
const response = await mailActions.getMails({
|
||||
type: 'SENT',
|
||||
page: page.value,
|
||||
size: pageSize.value
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
// 判断是否加载完成
|
||||
if (response.data.list.length < pageSize.value) {
|
||||
finished.value = true
|
||||
} else {
|
||||
page.value += 1
|
||||
}
|
||||
} else {
|
||||
showFailToast(response.message || '获取邮件列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取邮件列表失败:', error)
|
||||
showFailToast('获取邮件列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
const loadMore = () => {
|
||||
fetchMails()
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '未设置'
|
||||
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffTime = date - now
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays < 0) {
|
||||
// 过去的时间
|
||||
const pastDays = Math.abs(diffDays)
|
||||
if (pastDays === 0) return '今天'
|
||||
if (pastDays === 1) return '昨天'
|
||||
if (pastDays < 7) return `${pastDays}天前`
|
||||
if (pastDays < 30) return `${Math.floor(pastDays / 7)}周前`
|
||||
if (pastDays < 365) return `${Math.floor(pastDays / 30)}个月前`
|
||||
return `${Math.floor(pastDays / 365)}年前`
|
||||
} else {
|
||||
// 未来的时间
|
||||
if (diffDays === 0) return '今天'
|
||||
if (diffDays === 1) return '明天'
|
||||
if (diffDays < 7) return `${diffDays}天后`
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)}周后`
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)}个月后`
|
||||
return `${Math.floor(diffDays / 365)}年后`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态类型
|
||||
const getStatusType = (status) => {
|
||||
switch (status) {
|
||||
case 'DRAFT': return 'warning'
|
||||
case 'PENDING': return 'primary'
|
||||
case 'DELIVERING': return 'primary'
|
||||
case 'DELIVERED': return 'success'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'DRAFT': return '草稿'
|
||||
case 'PENDING': return '待投递'
|
||||
case 'DELIVERING': return '投递中'
|
||||
case 'DELIVERED': return '已送达'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取进度文本
|
||||
const getProgressText = (mail) => {
|
||||
if (mail.status === 'DRAFT') return '未设置投递时间'
|
||||
if (mail.status === 'DELIVERED') return '已完成'
|
||||
|
||||
const now = new Date()
|
||||
const sendTime = new Date(mail.sendTime)
|
||||
const deliveryTime = new Date(mail.deliveryTime)
|
||||
const total = deliveryTime - sendTime
|
||||
const elapsed = now - sendTime
|
||||
const percentage = Math.min(100, Math.max(0, (elapsed / total) * 100))
|
||||
|
||||
return `${Math.round(percentage)}%`
|
||||
}
|
||||
|
||||
// 获取进度百分比
|
||||
const getProgressPercentage = (mail) => {
|
||||
if (mail.status === 'DRAFT') return 0
|
||||
if (mail.status === 'DELIVERED') return 100
|
||||
|
||||
const now = new Date()
|
||||
const sendTime = new Date(mail.sendTime)
|
||||
const deliveryTime = new Date(mail.deliveryTime)
|
||||
const total = deliveryTime - sendTime
|
||||
const elapsed = now - sendTime
|
||||
return Math.min(100, Math.max(0, (elapsed / total) * 100))
|
||||
}
|
||||
|
||||
// 打开邮件
|
||||
const openMail = (mail) => {
|
||||
router.push(`/capsule/${mail.mailId}`)
|
||||
}
|
||||
|
||||
// 预览邮件
|
||||
const previewMail = (mail) => {
|
||||
previewMailData.value = mail
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
// 编辑邮件
|
||||
const editMail = (mail) => {
|
||||
router.push(`/compose?edit=${mail.mailId}`)
|
||||
}
|
||||
|
||||
// 取消邮件
|
||||
const cancelMail = (mail) => {
|
||||
Dialog.confirm({
|
||||
title: '确认撤销',
|
||||
message: '确定要撤销这封邮件的发送吗?撤销后将无法恢复。',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
const response = await mailActions.revokeMail(mail.mailId)
|
||||
if (response.code === 200) {
|
||||
showSuccessToast('邮件已撤销')
|
||||
// 重新加载邮件列表
|
||||
fetchMails(true)
|
||||
} else {
|
||||
showFailToast(response.message || '撤销失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('撤销邮件失败:', error)
|
||||
showFailToast('撤销失败')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 取消操作
|
||||
})
|
||||
}
|
||||
|
||||
// 应用排序
|
||||
const applySort = () => {
|
||||
showSort.value = false
|
||||
showFailToast('排序已应用')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateStars()
|
||||
fetchMails(true)
|
||||
})
|
||||
|
||||
return {
|
||||
active,
|
||||
stars,
|
||||
sortType,
|
||||
showSort,
|
||||
showPreview,
|
||||
previewMailData,
|
||||
sortedMails,
|
||||
loading,
|
||||
finished,
|
||||
goBack,
|
||||
formatDate,
|
||||
getStatusType,
|
||||
getStatusText,
|
||||
getProgressText,
|
||||
getProgressPercentage,
|
||||
openMail,
|
||||
previewMail,
|
||||
editMail,
|
||||
cancelMail,
|
||||
applySort,
|
||||
loadMore
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sent-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mail-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 15px 15px;
|
||||
}
|
||||
|
||||
.mail-item {
|
||||
display: flex;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mail-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.mail-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.time-capsule-small {
|
||||
width: 30px;
|
||||
height: 45px;
|
||||
background: var(--gradient-color);
|
||||
border-radius: 15px;
|
||||
position: relative;
|
||||
box-shadow: 0 5px 15px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.time-capsule-small::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: radial-gradient(circle, var(--accent-color), transparent);
|
||||
border-radius: 50%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.time-capsule-small.delivered::before {
|
||||
background: radial-gradient(circle, #4CAF50, transparent);
|
||||
}
|
||||
|
||||
.mail-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.mail-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mail-date {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mail-preview {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.mail-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mail-recipient {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mail-progress {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.progress-info span:first-child {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.progress-info span:last-child {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.mail-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.mail-actions .van-icon {
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.mail-actions .van-icon:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.sort-popup {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sort-popup h3 {
|
||||
margin: 0 0 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.sort-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.preview-popup {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.preview-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.preview-info p {
|
||||
margin: 5px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.preview-body p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
537
src/views/Timeline.vue
Normal file
537
src/views/Timeline.vue
Normal file
@@ -0,0 +1,537 @@
|
||||
<template>
|
||||
<div class="timeline-container">
|
||||
<!-- 深空背景 -->
|
||||
<div class="space-background">
|
||||
<div class="stars" ref="stars"></div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<div class="header glass-card">
|
||||
<van-icon name="arrow-left" size="24" @click="goBack" />
|
||||
<h2>我的时间线</h2>
|
||||
<van-icon name="filter-o" size="24" @click="showFilter = true" />
|
||||
</div>
|
||||
|
||||
<!-- 时间线内容 -->
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header glass-card">
|
||||
<div class="timeline-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ totalMails }}</div>
|
||||
<div class="stat-label">总邮件</div>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ timeSpan }}</div>
|
||||
<div class="stat-label">时间跨度(年)</div>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ activeYears }}</div>
|
||||
<div class="stat-label">活跃年份</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间轴 -->
|
||||
<div class="timeline">
|
||||
<div
|
||||
v-for="(year, index) in timelineData"
|
||||
:key="year.year"
|
||||
class="timeline-year"
|
||||
>
|
||||
<div class="year-marker" :style="{ animationDelay: `${index * 0.2}s` }">
|
||||
<div class="year-label">{{ year.year }}</div>
|
||||
<div class="year-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-items">
|
||||
<div
|
||||
v-for="(item, itemIndex) in year.items"
|
||||
:key="item.id"
|
||||
class="timeline-item glass-card"
|
||||
:class="{ 'sent': item.type === 'sent', 'received': item.type === 'received' }"
|
||||
:style="{ animationDelay: `${(index * 0.2) + (itemIndex * 0.1)}s` }"
|
||||
@click="viewItem(item)"
|
||||
>
|
||||
<div class="item-icon">
|
||||
<div class="capsule-icon" :class="item.type"></div>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-title">{{ item.title }}</div>
|
||||
<div class="item-date">{{ formatDate(item.date) }}</div>
|
||||
<div class="item-preview">{{ item.preview }}</div>
|
||||
</div>
|
||||
<div class="item-arrow">
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选弹窗 -->
|
||||
<van-popup v-model:show="showFilter" position="bottom" :style="{ height: '50%' }">
|
||||
<div class="filter-popup">
|
||||
<div class="filter-header">
|
||||
<h3>筛选时间线</h3>
|
||||
<van-icon name="cross" @click="showFilter = false" />
|
||||
</div>
|
||||
|
||||
<div class="filter-content">
|
||||
<div class="filter-section">
|
||||
<h4>类型筛选</h4>
|
||||
<van-radio-group v-model="filterType" direction="horizontal">
|
||||
<van-radio name="all">全部</van-radio>
|
||||
<van-radio name="sent">发送</van-radio>
|
||||
<van-radio name="received">接收</van-radio>
|
||||
</van-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>时间范围</h4>
|
||||
<van-radio-group v-model="filterTimeRange" direction="horizontal">
|
||||
<van-radio name="all">全部</van-radio>
|
||||
<van-radio name="year">今年</van-radio>
|
||||
<van-radio name="month">本月</van-radio>
|
||||
<van-radio name="week">本周</van-radio>
|
||||
</van-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>排序方式</h4>
|
||||
<van-radio-group v-model="filterSort" direction="horizontal">
|
||||
<van-radio name="time">时间顺序</van-radio>
|
||||
<van-radio name="reverse">时间倒序</van-radio>
|
||||
<van-radio name="type">类型排序</van-radio>
|
||||
</van-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<van-button round block class="filter-apply" @click="applyFilter">
|
||||
应用筛选
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<van-tabbar v-model="active" class="custom-tabbar">
|
||||
<van-tabbar-item icon="home-o" to="/home">时光胶囊</van-tabbar-item>
|
||||
<van-tabbar-item icon="envelop-o" to="/inbox">收件箱</van-tabbar-item>
|
||||
<van-tabbar-item icon="send-o" to="/sent">发件箱</van-tabbar-item>
|
||||
<van-tabbar-item icon="user-o" to="/profile">个人中心</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showFailToast } from 'vant'
|
||||
import { timelineActions, timelineState } from '../store'
|
||||
|
||||
export default {
|
||||
name: 'Timeline',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const active = ref(3)
|
||||
const stars = ref(null)
|
||||
const showFilter = ref(false)
|
||||
|
||||
// 使用直接导入的状态和操作
|
||||
const timeline = computed(() => timelineState.timeline)
|
||||
|
||||
// 筛选条件
|
||||
const filterType = ref('all')
|
||||
const filterTimeRange = ref('all')
|
||||
const filterSort = ref('time')
|
||||
|
||||
// 时间线数据
|
||||
const timelineData = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 从API获取时间线数据
|
||||
const fetchTimeline = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 获取时间线数据
|
||||
const response = await timelineActions.getTimeline()
|
||||
const timelineDataFromAPI = response.data.timeline
|
||||
|
||||
// 转换数据格式
|
||||
const formattedData = timelineDataFromAPI.map(year => ({
|
||||
year: parseInt(year.date),
|
||||
items: year.events.map(event => ({
|
||||
id: event.mailId,
|
||||
type: event.type.toLowerCase(), // API返回SENT/RECEIVED,转换为sent/received
|
||||
title: event.title,
|
||||
date: event.time,
|
||||
preview: event.title, // API没有返回preview,使用title代替
|
||||
status: event.type === 'SENT' ? '待投递' : '已送达'
|
||||
}))
|
||||
}))
|
||||
|
||||
timelineData.value = formattedData
|
||||
} catch (error) {
|
||||
showFailToast('获取时间线数据失败')
|
||||
console.error('获取时间线数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const totalMails = computed(() => {
|
||||
return timelineData.value.reduce((total, year) => total + year.items.length, 0)
|
||||
})
|
||||
|
||||
const timeSpan = computed(() => {
|
||||
const years = timelineData.value.map(y => y.year)
|
||||
return Math.max(...years) - Math.min(...years) + 1
|
||||
})
|
||||
|
||||
const activeYears = computed(() => {
|
||||
return timelineData.value.length
|
||||
})
|
||||
|
||||
// 生成星空背景
|
||||
const generateStars = () => {
|
||||
if (!stars.value) return
|
||||
|
||||
const starsContainer = stars.value
|
||||
const starCount = 150
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const star = document.createElement('div')
|
||||
star.className = 'star'
|
||||
|
||||
// 随机位置
|
||||
const left = Math.random() * 100
|
||||
const top = Math.random() * 100
|
||||
|
||||
// 随机大小
|
||||
const size = Math.random() * 3 + 1
|
||||
|
||||
// 随机动画延迟
|
||||
const delay = Math.random() * 4
|
||||
|
||||
star.style.left = `${left}%`
|
||||
star.style.top = `${top}%`
|
||||
star.style.width = `${size}px`
|
||||
star.style.height = `${size}px`
|
||||
star.style.animationDelay = `${delay}s`
|
||||
|
||||
starsContainer.appendChild(star)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// 查看时间线项
|
||||
const viewItem = (item) => {
|
||||
router.push(`/capsule/${item.id}`)
|
||||
}
|
||||
|
||||
// 应用筛选
|
||||
const applyFilter = () => {
|
||||
showFilter.value = false
|
||||
showFailToast('筛选功能开发中')
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateStars()
|
||||
fetchTimeline()
|
||||
})
|
||||
|
||||
return {
|
||||
active,
|
||||
stars,
|
||||
timelineData,
|
||||
showFilter,
|
||||
filterType,
|
||||
filterTimeRange,
|
||||
filterSort,
|
||||
loading,
|
||||
totalMails,
|
||||
timeSpan,
|
||||
activeYears,
|
||||
formatDate,
|
||||
viewItem,
|
||||
applyFilter,
|
||||
goBack
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
margin: 15px 15px 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
padding: 0 15px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.timeline-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.timeline-year {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.year-marker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: var(--accent-color);
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.year-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent-color), transparent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.timeline-items {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-items::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -15px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg, var(--accent-color), transparent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-item:hover {
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.timeline-item.sent {
|
||||
border-left: 3px solid #00D4FF;
|
||||
}
|
||||
|
||||
.timeline-item.received {
|
||||
border-left: 3px solid #4ECDC4;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.capsule-icon {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.capsule-icon.sent {
|
||||
background: linear-gradient(135deg, #00D4FF, #0099CC);
|
||||
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
|
||||
.capsule-icon.received {
|
||||
background: linear-gradient(135deg, #4ECDC4, #2A9D8F);
|
||||
box-shadow: 0 0 10px rgba(78, 205, 196, 0.5);
|
||||
}
|
||||
|
||||
.capsule-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 30px;
|
||||
height: 2px;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.item-date {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-preview {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-popup {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.filter-section h4 {
|
||||
margin: 0 0 15px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.filter-apply {
|
||||
background: linear-gradient(135deg, var(--accent-color), #0099CC);
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.custom-tabbar {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
17
vue.config.js
Normal file
17
vue.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
devServer: {
|
||||
port: 8080,
|
||||
open: true,
|
||||
// 反向代理5001
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5001',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user