初始化

This commit is contained in:
2025-10-16 09:59:34 +08:00
commit dd3936944b
32 changed files with 20220 additions and 0 deletions

31
src/App.vue Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>