feat: 完成情绪博物馆项目重构和功能增强 - 新增日记评论和帖子功能 - 重构前端架构,优化用户体验 - 完善WebSocket通信机制 - 更新项目文档和部署配置

This commit is contained in:
2025-07-27 10:05:59 +08:00
parent 6903ac1c0d
commit cc886cd4d5
126 changed files with 21179 additions and 15734 deletions
+39
View File
@@ -0,0 +1,39 @@
<template>
<div class="analysis-page">
<div class="container mx-auto px-4 py-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">情绪分析</h1>
<p class="text-gray-600">深度分析情绪数据发现情绪规律</p>
</div>
<div class="card">
<div class="text-center text-gray-500 py-12">
<el-icon class="text-4xl mb-4">
<TrendCharts />
</el-icon>
<p>情绪分析功能开发中...</p>
<p class="text-sm mt-2">这里将展示情绪趋势雷达图热力图等可视化内容</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { TrendCharts } from '@element-plus/icons-vue'
</script>
<style scoped>
.analysis-page {
min-height: 100vh;
background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%);
background-attachment: fixed;
}
.analysis-page .container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
margin-top: 2rem;
margin-bottom: 2rem;
}
</style>
-644
View File
@@ -1,644 +0,0 @@
<template>
<div class="chat-history-page">
<!-- 头部 -->
<header class="page-header">
<div class="header-content">
<div class="header-left">
<a-button type="text" @click="$router.back()" class="back-btn">
<ArrowLeftOutlined />
</a-button>
<h1 class="page-title">聊天历史</h1>
</div>
<a-button type="text" @click="showSearchModal = true" class="search-btn">
<SearchOutlined />
搜索
</a-button>
</div>
</header>
<!-- 主要内容 -->
<main class="page-main">
<div class="container">
<!-- 会话列表 -->
<div class="sessions-list">
<div
v-for="session in chatStore.sessions"
:key="session.id"
class="session-item"
@click="viewSession(session)"
>
<div class="session-avatar">
<a-avatar :src="kaikaiAvatar" :size="48" />
</div>
<div class="session-content">
<div class="session-header">
<h3 class="session-title">{{ session.title }}</h3>
<span class="session-time">{{ formatTime.friendly(session.updateTime) }}</span>
</div>
<div class="session-info">
<span class="message-count">{{ session.messageCount }} 条消息</span>
<span class="session-date">{{ formatTime.date(session.createTime) }}</span>
</div>
</div>
<div class="session-actions">
<a-dropdown @click.stop>
<a-button type="text" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="renameSession(session)">
<EditOutlined />
重命名
</a-menu-item>
<a-menu-item @click="exportSession(session)">
<DownloadOutlined />
导出
</a-menu-item>
<a-menu-item @click="deleteSession(session.id)" danger>
<DeleteOutlined />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="chatStore.sessions.length === 0" class="empty-state">
<a-empty
description="暂无聊天记录"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
>
<a-button type="primary" @click="$router.push('/chat')">
开始新对话
</a-button>
</a-empty>
</div>
</div>
</main>
<!-- 搜索模态框 -->
<a-modal
v-model:open="showSearchModal"
title="搜索聊天记录"
:footer="null"
width="600px"
>
<div class="search-content">
<a-input-search
v-model:value="searchKeyword"
placeholder="输入关键词搜索..."
@search="handleSearch"
size="large"
style="margin-bottom: 16px"
/>
<div class="search-filters">
<a-date-picker
v-model:value="searchDate"
placeholder="按日期筛选"
style="width: 100%; margin-bottom: 16px"
/>
</div>
<div class="search-results" v-if="searchResults.length > 0">
<h4>搜索结果 ({{ searchResults.length }})</h4>
<div class="results-list">
<div
v-for="result in searchResults"
:key="result.id"
class="result-item"
@click="viewSearchResult(result)"
>
<div class="result-content">
<div class="result-text">{{ result.content }}</div>
<div class="result-meta">
<span class="result-type">{{ result.type === 'user' ? '我' : '开开' }}</span>
<span class="result-time">{{ formatTime.standard(result.timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="searchKeyword && hasSearched" class="no-results">
<a-empty description="未找到相关消息" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
</div>
</a-modal>
<!-- 会话详情模态框 -->
<a-modal
v-model:open="showSessionModal"
:title="selectedSession?.title"
:footer="null"
width="800px"
:body-style="{ maxHeight: '60vh', overflow: 'auto' }"
>
<div v-if="selectedSession" class="session-detail">
<div class="session-info-header">
<div class="info-item">
<span class="info-label">创建时间</span>
<span class="info-value">{{ formatTime.standard(selectedSession.createTime) }}</span>
</div>
<div class="info-item">
<span class="info-label">最后更新</span>
<span class="info-value">{{ formatTime.standard(selectedSession.updateTime) }}</span>
</div>
<div class="info-item">
<span class="info-label">消息数量</span>
<span class="info-value">{{ selectedSession.messageCount }} </span>
</div>
</div>
<div class="session-messages">
<div
v-for="message in sessionMessages"
:key="message.id"
class="message-item"
:class="{ 'user-message': message.type === 'user' }"
>
<div class="message-avatar" v-if="message.type === 'ai'">
<a-avatar :src="kaikaiAvatar" :size="32" />
</div>
<div class="message-bubble">
<div class="message-text">{{ message.content }}</div>
<div class="message-time">{{ formatTime.friendly(message.timestamp) }}</div>
</div>
</div>
</div>
<div class="session-actions">
<a-button @click="continueSession(selectedSession)" type="primary">
继续对话
</a-button>
<a-button @click="exportSession(selectedSession)">
导出记录
</a-button>
</div>
</div>
</a-modal>
<!-- 重命名模态框 -->
<a-modal
v-model:open="showRenameModal"
title="重命名会话"
@ok="confirmRename"
@cancel="cancelRename"
>
<a-input
v-model:value="newSessionName"
placeholder="请输入新的会话名称"
:maxlength="50"
show-count
/>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
ArrowLeftOutlined,
SearchOutlined,
MoreOutlined,
EditOutlined,
DownloadOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import { Empty, message } from 'ant-design-vue'
import { useChatStore } from '@/stores'
import { formatTime } from '@/utils'
import type { ChatSession, ChatMessage } from '@/types'
import type { Dayjs } from 'dayjs'
const chatStore = useChatStore()
// 响应式数据
const showSearchModal = ref(false)
const showSessionModal = ref(false)
const showRenameModal = ref(false)
const searchKeyword = ref('')
const searchDate = ref<Dayjs | null>(null)
const hasSearched = ref(false)
const selectedSession = ref<ChatSession | null>(null)
const sessionToRename = ref<ChatSession | null>(null)
const newSessionName = ref('')
// 开开头像
const kaikaiAvatar = 'https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png'
// 模拟会话消息数据
const sessionMessages = ref<ChatMessage[]>([])
// 搜索结果
const searchResults = ref<ChatMessage[]>([])
// 计算属性
// const filteredSessions = computed(() => {
// return chatStore.sessions.sort((a, b) =>
// new Date(b.updateTime).getTime() - new Date(a.updateTime).getTime()
// )
// })
// 方法
const viewSession = (session: ChatSession) => {
selectedSession.value = session
loadSessionMessages(session.id)
showSessionModal.value = true
}
const loadSessionMessages = async (sessionId: string) => {
try {
// TODO: 从API加载会话消息
// const messages = await chatApi.getSessionMessages(sessionId)
// 模拟消息数据
sessionMessages.value = [
{
id: '1',
content: '你好,开开!',
type: 'user',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
sessionId
},
{
id: '2',
content: '你好!很高兴见到你,有什么我可以帮助你的吗?',
type: 'ai',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000 + 30000).toISOString(),
sessionId
}
]
} catch (error) {
message.error('加载消息失败')
}
}
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
message.warning('请输入搜索关键词')
return
}
hasSearched.value = true
try {
// TODO: 调用搜索API
// const results = await chatApi.searchMessages(searchKeyword.value, searchDate.value)
// 模拟搜索结果
searchResults.value = [
{
id: '1',
content: `这是包含"${searchKeyword.value}"的消息内容...`,
type: 'user',
timestamp: new Date().toISOString(),
sessionId: '1'
}
]
} catch (error) {
message.error('搜索失败')
}
}
const viewSearchResult = (result: ChatMessage) => {
// 跳转到对应的会话
const session = chatStore.sessions.find(s => s.id === result.sessionId)
if (session) {
showSearchModal.value = false
viewSession(session)
}
}
const renameSession = (session: ChatSession) => {
sessionToRename.value = session
newSessionName.value = session.title
showRenameModal.value = true
}
const confirmRename = async () => {
if (!newSessionName.value.trim()) {
message.warning('请输入会话名称')
return
}
if (sessionToRename.value) {
try {
// await chatStore.updateSessionTitle(sessionToRename.value.id, newSessionName.value.trim())
console.log('重命名会话:', sessionToRename.value.id, newSessionName.value.trim())
message.success('重命名成功')
showRenameModal.value = false
} catch (error) {
message.error('重命名失败')
}
}
}
const cancelRename = () => {
sessionToRename.value = null
newSessionName.value = ''
}
const exportSession = (_session: ChatSession) => {
// TODO: 实现导出功能
message.info('导出功能开发中...')
}
const deleteSession = async (sessionId: string) => {
try {
await chatStore.deleteSession(sessionId)
message.success('会话删除成功')
} catch (error) {
message.error('删除失败')
}
}
const continueSession = (session: ChatSession) => {
chatStore.switchSession(session.id)
showSessionModal.value = false
// 跳转到聊天页面
// router.push('/chat')
}
// 组件挂载
onMounted(() => {
// 初始化数据
})
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.chat-history-page {
min-height: 100vh;
background: $light-gray;
}
.page-header {
background: white;
box-shadow: $shadow-sm;
position: sticky;
top: 0;
z-index: 10;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
max-width: 800px;
margin: 0 auto;
}
.header-left {
display: flex;
align-items: center;
gap: $spacing-md;
}
.back-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
.page-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-dark;
margin: 0;
}
.search-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
.page-main {
padding: $spacing-lg;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.sessions-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.session-item {
background: white;
border-radius: $border-radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-sm;
display: flex;
gap: $spacing-md;
cursor: pointer;
transition: all $transition-normal;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-1px);
}
.session-avatar {
flex-shrink: 0;
}
.session-content {
flex: 1;
min-width: 0;
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-xs;
.session-title {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-dark;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-time {
font-size: $font-size-sm;
color: $text-medium;
flex-shrink: 0;
margin-left: $spacing-md;
}
}
.session-info {
display: flex;
gap: $spacing-md;
font-size: $font-size-sm;
color: $text-medium;
.message-count {
color: $tech-blue;
}
}
}
.session-actions {
flex-shrink: 0;
}
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: $spacing-xxl;
}
// 搜索模态框样式
.search-content {
.search-results {
h4 {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: $spacing-md;
}
.results-list {
max-height: 300px;
overflow-y: auto;
}
.result-item {
padding: $spacing-md;
border-radius: $border-radius-md;
cursor: pointer;
transition: background-color $transition-normal;
&:hover {
background: $light-gray;
}
.result-content {
.result-text {
color: $text-dark;
line-height: 1.5;
margin-bottom: $spacing-xs;
}
.result-meta {
display: flex;
gap: $spacing-md;
font-size: $font-size-sm;
color: $text-medium;
.result-type {
font-weight: $font-weight-medium;
}
}
}
}
}
.no-results {
text-align: center;
padding: $spacing-xl;
}
}
// 会话详情模态框样式
.session-detail {
.session-info-header {
display: flex;
flex-direction: column;
gap: $spacing-xs;
margin-bottom: $spacing-lg;
padding: $spacing-md;
background: $light-gray;
border-radius: $border-radius-md;
.info-item {
display: flex;
gap: $spacing-sm;
.info-label {
font-weight: $font-weight-medium;
color: $text-medium;
min-width: 80px;
}
.info-value {
color: $text-dark;
}
}
}
.session-messages {
max-height: 400px;
overflow-y: auto;
margin-bottom: $spacing-lg;
.message-item {
display: flex;
gap: $spacing-sm;
margin-bottom: $spacing-md;
&.user-message {
flex-direction: row-reverse;
.message-bubble {
background: $tech-blue;
color: white;
border-radius: 18px 18px 4px 18px;
}
}
.message-avatar {
flex-shrink: 0;
}
.message-bubble {
background: white;
border-radius: 18px 18px 18px 4px;
padding: $spacing-md;
box-shadow: $shadow-sm;
max-width: 70%;
.message-text {
line-height: 1.5;
margin-bottom: $spacing-xs;
}
.message-time {
font-size: $font-size-xs;
opacity: 0.7;
}
}
}
}
.session-actions {
display: flex;
gap: $spacing-md;
padding-top: $spacing-md;
border-top: 1px solid #f0f0f0;
}
}
</style>
+462 -1126
View File
File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
<template>
<div class="bg-light-gray font-sans text-text-dark flex flex-col h-screen antialiased">
<!-- Header -->
<header class="bg-white shadow-md z-10 flex-shrink-0">
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center space-x-3">
<router-link to="/chat" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="chevron-left" class="w-6 h-6"></i>
</router-link>
<h1 class="text-lg font-bold text-text-dark">聊天记录</h1>
</div>
<button
@click="toggleSearch"
class="text-text-medium hover:text-tech-blue transition-colors"
>
<i data-lucide="search" class="w-5 h-5"></i>
</button>
</div>
</header>
<!-- Search Bar -->
<div v-if="searchOpen" class="bg-white border-b border-gray-200 px-4 py-3">
<div class="relative">
<input
v-model="searchKeyword"
@input="handleSearch"
type="text"
placeholder="搜索聊天记录..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-tech-blue focus:border-transparent"
>
<i data-lucide="search" class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"></i>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-tech-blue mx-auto mb-2"></div>
<p class="text-text-medium">加载聊天记录中...</p>
</div>
</div>
<!-- Chat History List -->
<main v-else id="history-list" class="flex-1 overflow-y-auto p-4 lg:p-6 space-y-3">
<!-- 无数据状态 -->
<div v-if="chatHistoryData.length === 0" class="text-center py-12">
<i data-lucide="message-circle" class="w-16 h-16 text-gray-300 mx-auto mb-4"></i>
<p class="text-text-medium">暂无聊天记录</p>
<router-link to="/chat" class="inline-block mt-4 px-6 py-2 bg-tech-blue text-white rounded-lg hover:bg-blue-600 transition-colors">
开始聊天
</router-link>
</div>
<!-- 聊天记录列表 -->
<div
v-for="item in chatHistoryData"
:key="item.id"
@click="goToChat(item.id)"
class="block bg-white p-4 rounded-xl shadow-sm hover:shadow-md hover:border-tech-blue/50 border border-transparent transition-all duration-300 group cursor-pointer"
>
<div class="flex justify-between items-center">
<div class="flex-1 min-w-0">
<p class="text-sm text-text-medium mb-1 group-hover:text-tech-blue transition-colors">{{ formatDate(item.createTime) }}</p>
<p class="font-medium text-text-dark truncate">{{ item.title }}</p>
<p class="text-xs text-gray-500 mt-1">{{ item.messageCount }} 条消息</p>
</div>
<i data-lucide="chevron-right" class="w-5 h-5 text-gray-300 group-hover:text-tech-blue transition-colors flex-shrink-0 ml-4"></i>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore && !loading" class="text-center py-4">
<button
@click="loadMore"
class="px-6 py-2 text-tech-blue border border-tech-blue rounded-lg hover:bg-tech-blue hover:text-white transition-colors"
>
加载更多
</button>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { chatApi } from '@/services/chat'
import { messageApi } from '@/services/message'
import { useAuthStore } from '@/stores/auth'
import type { ChatSession } from '@/types'
const router = useRouter()
const authStore = useAuthStore()
// 响应式数据
const searchOpen = ref(false)
const searchKeyword = ref('')
const loading = ref(false)
const chatHistoryData = ref<ChatSession[]>([])
const currentPage = ref(1)
const pageSize = ref(20)
const hasMore = ref(true)
// 方法
const toggleSearch = () => {
searchOpen.value = !searchOpen.value
if (!searchOpen.value) {
searchKeyword.value = ''
loadChatHistory() // 重新加载完整列表
}
}
const formatDate = (dateInput: string | Date) => {
try {
let date: Date
if (dateInput instanceof Date) {
if (isNaN(dateInput.getTime())) {
return '日期无效'
}
date = dateInput
} else if (typeof dateInput === 'string') {
// 精确匹配后端格式 "2025-07-26 22:09:10"
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateInput)) {
const dateStr = dateInput.replace(' ', 'T')
date = new Date(dateStr)
} else {
date = new Date(dateInput)
}
} else {
return '未知日期'
}
if (isNaN(date.getTime())) {
return '日期无效'
}
const now = new Date()
const diffTime = Math.abs(now.getTime() - date.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays === 1) {
return '今天'
} else if (diffDays === 2) {
return '昨天'
} else if (diffDays <= 7) {
return `${diffDays}天前`
} else {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
} catch (error) {
return '日期错误'
}
}
const goToChat = (sessionId: string) => {
router.push(`/chat?session=${sessionId}`)
}
const loadChatHistory = async (page: number = 1) => {
try {
loading.value = true
console.log('📂 加载聊天历史:', { page, pageSize: pageSize.value })
// 获取当前用户ID
const currentUserId = authStore.userInfo?.id || authStore.userId
if (!currentUserId) {
console.warn('⚠️ 未找到用户ID,无法加载聊天历史')
return
}
// 获取用户的所有会话
const userSessions = await chatApi.getUserSessions(currentUserId)
if (page === 1) {
chatHistoryData.value = userSessions
} else {
chatHistoryData.value.push(...userSessions)
}
hasMore.value = userSessions.length === pageSize.value
console.log('✅ 聊天历史加载完成:', chatHistoryData.value.length)
} catch (error) {
console.error('❌ 加载聊天历史失败:', error)
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!loading.value && hasMore.value) {
currentPage.value++
loadChatHistory(currentPage.value)
}
}
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
loadChatHistory()
return
}
try {
loading.value = true
console.log('🔍 搜索聊天记录:', searchKeyword.value)
// 使用消息搜索API来查找相关会话
const searchResults = await messageApi.searchUserMessages(searchKeyword.value, 50)
// 从搜索结果中提取唯一的会话ID
const conversationIds = [...new Set(searchResults.data?.map((msg: any) => msg.conversationId) || [])]
// 过滤出匹配的会话
const filteredSessions = chatHistoryData.value.filter(session =>
conversationIds.includes(session.id) ||
session.title.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
chatHistoryData.value = filteredSessions
console.log('✅ 搜索完成,找到', filteredSessions.length, '个会话')
} catch (error) {
console.error('❌ 搜索失败:', error)
} finally {
loading.value = false
}
}
// 生命周期
onMounted(async () => {
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
// 延迟初始化图标
setTimeout(() => {
if (window.lucide) {
window.lucide.createIcons()
}
}, 100)
// 加载聊天历史
await loadChatHistory()
})
</script>
<style scoped>
/* 导入原始样式变量 */
:root {
--tech-blue: #4A90E2;
--warm-orange: #F5A623;
--white: #FFFFFF;
--light-gray: #F7F8FA;
--text-dark: #333333;
--text-medium: #888888;
}
/* 应用原始样式类 */
.bg-tech-blue { background-color: var(--tech-blue); }
.bg-warm-orange { background-color: var(--warm-orange); }
.bg-light-gray { background-color: var(--light-gray); }
.text-tech-blue { color: var(--tech-blue); }
.text-text-dark { color: var(--text-dark); }
.text-text-medium { color: var(--text-medium); }
.border-tech-blue { border-color: var(--tech-blue); }
.chat-history-page {
font-family: 'Noto Sans SC', sans-serif;
background-color: var(--light-gray);
color: var(--text-dark);
display: flex;
flex-direction: column;
height: 100vh;
}
/* 全局样式 */
body {
font-family: 'Noto Sans SC', sans-serif;
}
</style>
-695
View File
@@ -1,695 +0,0 @@
<template>
<div class="dashboard-page">
<!-- 头部 -->
<header class="page-header">
<div class="header-content">
<div class="header-left">
<a-button type="text" @click="$router.back()" class="back-btn">
<ArrowLeftOutlined />
</a-button>
<h1 class="page-title">个人展板</h1>
</div>
<a-button type="text" @click="editMode = !editMode" class="edit-btn">
<EditOutlined />
{{ editMode ? '完成' : '编辑' }}
</a-button>
</div>
</header>
<!-- 主要内容 -->
<main class="page-main">
<div class="container">
<div class="dashboard-grid">
<!-- 基础信息卡片 -->
<a-card class="info-card" title="基础信息">
<template #extra>
<UserOutlined class="card-icon" />
</template>
<div class="basic-info">
<div class="info-item">
<span class="info-label">昵称</span>
<span class="info-value">{{ personalInfo.nickname || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">年龄</span>
<span class="info-value">{{ personalInfo.age || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">职业</span>
<span class="info-value">{{ personalInfo.occupation || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">地区</span>
<span class="info-value">{{ personalInfo.location || '未设置' }}</span>
</div>
</div>
<a-button v-if="editMode" type="link" @click="showBasicInfoModal = true">
编辑信息
</a-button>
</a-card>
<!-- 心情统计卡片 -->
<a-card class="chart-card" title="近期心情统计">
<template #extra>
<BarChartOutlined class="card-icon" />
</template>
<div class="chart-container">
<canvas ref="moodChartRef" class="mood-chart"></canvas>
</div>
</a-card>
<!-- 兴趣爱好卡片 -->
<a-card class="interests-card" title="兴趣爱好">
<template #extra>
<div class="card-extra">
<HeartOutlined class="card-icon" />
<a-button
v-if="editMode"
type="text"
size="small"
@click="showAddInterestModal = true"
>
<PlusOutlined />
</a-button>
</div>
</template>
<div class="tags-container">
<a-tag
v-for="interest in personalInfo.interests"
:key="interest"
:closable="editMode"
@close="removeInterest(interest)"
color="blue"
class="interest-tag"
>
{{ interest }}
</a-tag>
<a-tag
v-if="personalInfo.interests.length === 0"
class="empty-tag"
>
暂无兴趣爱好
</a-tag>
</div>
<a-button
v-if="!editMode"
type="link"
@click="exploreInterests"
class="explore-btn"
>
<StarOutlined />
探索可能发展的爱好
</a-button>
</a-card>
<!-- 生活技能卡片 -->
<a-card class="skills-card" title="生活技能">
<template #extra>
<div class="card-extra">
<ToolOutlined class="card-icon" />
<a-button
v-if="editMode"
type="text"
size="small"
@click="showAddSkillModal = true"
>
<PlusOutlined />
</a-button>
</div>
</template>
<div class="tags-container">
<a-tag
v-for="skill in personalInfo.skills"
:key="skill"
:closable="editMode"
@close="removeSkill(skill)"
color="green"
class="skill-tag"
>
{{ skill }}
</a-tag>
<a-tag
v-if="personalInfo.skills.length === 0"
class="empty-tag"
>
暂无技能记录
</a-tag>
</div>
<a-button
v-if="!editMode"
type="link"
@click="exploreSkills"
class="explore-btn"
>
<ExperimentOutlined />
探索可能发展的技能
</a-button>
</a-card>
<!-- 个人语录卡片 -->
<a-card class="quotes-card full-width" title="个人语录">
<template #extra>
<div class="card-extra">
<MessageOutlined class="card-icon" />
<a-button
v-if="editMode"
type="text"
size="small"
@click="showAddQuoteModal = true"
>
<PlusOutlined />
</a-button>
</div>
</template>
<div class="quotes-container">
<div
v-for="quote in personalInfo.quotes"
:key="quote.id"
class="quote-item"
>
<div class="quote-content">
<blockquote class="quote-text">"{{ quote.content }}"</blockquote>
<div class="quote-meta">
<span class="quote-date">{{ formatTime.date(quote.createTime) }}</span>
<span v-if="quote.source" class="quote-source">来源{{ quote.source }}</span>
</div>
</div>
<a-button
v-if="editMode"
type="text"
size="small"
danger
@click="removeQuote(quote.id)"
>
<DeleteOutlined />
</a-button>
</div>
<div v-if="personalInfo.quotes.length === 0" class="empty-quotes">
<a-empty description="暂无个人语录" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
</div>
</a-card>
</div>
<!-- 添加自定义模块按钮 -->
<div class="add-module-section" v-if="editMode">
<a-button type="dashed" size="large" class="add-module-btn">
<PlusOutlined />
自由添加模块
</a-button>
</div>
</div>
</main>
<!-- 基础信息编辑模态框 -->
<a-modal
v-model:open="showBasicInfoModal"
title="编辑基础信息"
@ok="saveBasicInfo"
@cancel="resetBasicInfo"
>
<a-form :model="basicInfoForm" layout="vertical">
<a-form-item label="昵称">
<a-input v-model:value="basicInfoForm.nickname" placeholder="请输入昵称" />
</a-form-item>
<a-form-item label="年龄">
<a-input-number
v-model:value="basicInfoForm.age"
:min="1"
:max="120"
placeholder="请输入年龄"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="职业">
<a-input v-model:value="basicInfoForm.occupation" placeholder="请输入职业" />
</a-form-item>
<a-form-item label="地区">
<a-input v-model:value="basicInfoForm.location" placeholder="请输入地区" />
</a-form-item>
</a-form>
</a-modal>
<!-- 添加兴趣模态框 -->
<a-modal
v-model:open="showAddInterestModal"
title="添加兴趣爱好"
@ok="addInterest"
@cancel="newInterest = ''"
>
<a-input
v-model:value="newInterest"
placeholder="请输入兴趣爱好"
@press-enter="addInterest"
/>
</a-modal>
<!-- 添加技能模态框 -->
<a-modal
v-model:open="showAddSkillModal"
title="添加生活技能"
@ok="addSkill"
@cancel="newSkill = ''"
>
<a-input
v-model:value="newSkill"
placeholder="请输入生活技能"
@press-enter="addSkill"
/>
</a-modal>
<!-- 添加语录模态框 -->
<a-modal
v-model:open="showAddQuoteModal"
title="添加个人语录"
@ok="addQuote"
@cancel="resetQuoteForm"
>
<a-form :model="quoteForm" layout="vertical">
<a-form-item label="语录内容" required>
<a-textarea
v-model:value="quoteForm.content"
placeholder="请输入语录内容"
:rows="3"
/>
</a-form-item>
<a-form-item label="来源">
<a-input v-model:value="quoteForm.source" placeholder="请输入来源(可选)" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import {
ArrowLeftOutlined,
EditOutlined,
UserOutlined,
BarChartOutlined,
HeartOutlined,
ToolOutlined,
MessageOutlined,
PlusOutlined,
DeleteOutlined,
StarOutlined,
ExperimentOutlined,
} from '@ant-design/icons-vue'
import { Empty, message } from 'ant-design-vue'
import { Chart, registerables } from 'chart.js'
import { formatTime } from '@/utils'
import type { PersonalInfo, PersonalQuote } from '@/types'
// 注册Chart.js组件
Chart.register(...registerables)
// 响应式数据
const editMode = ref(false)
const showBasicInfoModal = ref(false)
const showAddInterestModal = ref(false)
const showAddSkillModal = ref(false)
const showAddQuoteModal = ref(false)
const newInterest = ref('')
const newSkill = ref('')
const moodChartRef = ref<HTMLCanvasElement>()
let moodChart: Chart | null = null
console.log('moodChart initialized:', moodChart) // 避免未使用警告
// 个人信息数据
const personalInfo = reactive<PersonalInfo>({
id: '1',
userId: '1',
nickname: '开心用户',
age: 25,
occupation: '软件工程师',
location: '北京',
interests: ['阅读', '旅行', '摄影', '音乐'],
skills: ['编程', '设计', '写作', '烹饪'],
quotes: [
{
id: '1',
content: '生活不是等待暴风雨过去,而是学会在雨中跳舞',
createTime: new Date().toISOString(),
source: '电影台词'
}
],
updateTime: new Date().toISOString()
})
// 表单数据
const basicInfoForm = reactive({
nickname: '',
age: undefined as number | undefined,
occupation: '',
location: ''
})
const quoteForm = reactive({
content: '',
source: ''
})
// 方法
const saveBasicInfo = () => {
Object.assign(personalInfo, basicInfoForm)
showBasicInfoModal.value = false
message.success('基础信息保存成功')
}
const resetBasicInfo = () => {
basicInfoForm.nickname = personalInfo.nickname || ''
basicInfoForm.age = personalInfo.age
basicInfoForm.occupation = personalInfo.occupation || ''
basicInfoForm.location = personalInfo.location || ''
}
const addInterest = () => {
if (newInterest.value.trim() && !personalInfo.interests.includes(newInterest.value.trim())) {
personalInfo.interests.push(newInterest.value.trim())
newInterest.value = ''
showAddInterestModal.value = false
message.success('兴趣爱好添加成功')
}
}
const removeInterest = (interest: string) => {
const index = personalInfo.interests.indexOf(interest)
if (index > -1) {
personalInfo.interests.splice(index, 1)
}
}
const addSkill = () => {
if (newSkill.value.trim() && !personalInfo.skills.includes(newSkill.value.trim())) {
personalInfo.skills.push(newSkill.value.trim())
newSkill.value = ''
showAddSkillModal.value = false
message.success('生活技能添加成功')
}
}
const removeSkill = (skill: string) => {
const index = personalInfo.skills.indexOf(skill)
if (index > -1) {
personalInfo.skills.splice(index, 1)
}
}
const addQuote = () => {
if (quoteForm.content.trim()) {
const newQuote: PersonalQuote = {
id: Date.now().toString(),
content: quoteForm.content.trim(),
createTime: new Date().toISOString(),
source: quoteForm.source.trim() || undefined
}
personalInfo.quotes.unshift(newQuote)
resetQuoteForm()
showAddQuoteModal.value = false
message.success('个人语录添加成功')
}
}
const removeQuote = (id: string) => {
const index = personalInfo.quotes.findIndex(q => q.id === id)
if (index > -1) {
personalInfo.quotes.splice(index, 1)
}
}
const resetQuoteForm = () => {
quoteForm.content = ''
quoteForm.source = ''
}
const exploreInterests = () => {
message.info('兴趣探索功能开发中...')
}
const exploreSkills = () => {
message.info('技能探索功能开发中...')
}
// 初始化心情图表
const initMoodChart = () => {
nextTick(() => {
if (moodChartRef.value) {
const ctx = moodChartRef.value.getContext('2d')
if (ctx) {
moodChart = new Chart(ctx, {
type: 'line',
data: {
labels: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
datasets: [{
label: '心情指数',
data: [7, 8, 6, 9, 7, 8, 9],
borderColor: '#4A90E2',
backgroundColor: 'rgba(74, 144, 226, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 10,
grid: {
color: 'rgba(0, 0, 0, 0.1)'
}
},
x: {
grid: {
display: false
}
}
}
}
})
}
}
})
}
// 组件挂载
onMounted(() => {
resetBasicInfo()
initMoodChart()
})
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.dashboard-page {
min-height: 100vh;
background: $light-gray;
}
.page-header {
background: white;
box-shadow: $shadow-sm;
position: sticky;
top: 0;
z-index: 10;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
max-width: 1200px;
margin: 0 auto;
}
.header-left {
display: flex;
align-items: center;
gap: $spacing-md;
}
.back-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
.page-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-dark;
margin: 0;
}
.edit-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
.page-main {
padding: $spacing-lg;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr;
gap: $spacing-lg;
@media (min-width: $breakpoint-lg) {
grid-template-columns: repeat(2, 1fr);
}
}
.full-width {
@media (min-width: $breakpoint-lg) {
grid-column: 1 / -1;
}
}
.card-icon {
color: $tech-blue;
}
.card-extra {
display: flex;
align-items: center;
gap: $spacing-sm;
}
// 基础信息卡片
.basic-info {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-md;
}
.info-item {
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.info-label {
font-size: $font-size-sm;
color: $text-medium;
font-weight: $font-weight-medium;
}
.info-value {
color: $text-dark;
}
// 图表卡片
.chart-container {
height: 200px;
position: relative;
}
.mood-chart {
width: 100% !important;
height: 100% !important;
}
// 标签容器
.tags-container {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
margin-bottom: $spacing-md;
min-height: 32px;
}
.interest-tag,
.skill-tag {
margin: 0;
}
.empty-tag {
color: $text-medium;
background: transparent;
border: 1px dashed #d9d9d9;
}
.explore-btn {
padding: 0;
height: auto;
font-size: $font-size-sm;
}
// 语录卡片
.quotes-container {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.quote-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: $spacing-md;
background: rgba(74, 144, 226, 0.05);
border-radius: $border-radius-md;
border-left: 3px solid $tech-blue;
}
.quote-content {
flex: 1;
}
.quote-text {
font-style: italic;
color: $text-dark;
margin: 0 0 $spacing-xs 0;
line-height: 1.5;
}
.quote-meta {
display: flex;
gap: $spacing-md;
font-size: $font-size-sm;
color: $text-medium;
}
.empty-quotes {
text-align: center;
padding: $spacing-xl;
}
// 添加模块区域
.add-module-section {
margin-top: $spacing-xl;
text-align: center;
}
.add-module-btn {
width: 100%;
height: 80px;
font-size: $font-size-lg;
border-radius: $border-radius-lg;
border: 2px dashed #d9d9d9;
&:hover {
border-color: $tech-blue;
color: $tech-blue;
}
}
</style>
+195
View File
@@ -0,0 +1,195 @@
<template>
<div class="p-6 max-w-4xl mx-auto">
<h1 class="text-2xl font-bold mb-6">WebSocket连接测试</h1>
<!-- 连接状态 -->
<div class="mb-6 p-4 rounded-lg" :class="statusClass">
<h2 class="text-lg font-semibold mb-2">连接状态</h2>
<p>状态: {{ connectionStatus }}</p>
<p>WebSocket URL: {{ wsUrl }}</p>
<p>用户ID: {{ userId }}</p>
</div>
<!-- 控制按钮 -->
<div class="mb-6 space-x-4">
<button
@click="connect"
:disabled="isConnected"
class="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
连接
</button>
<button
@click="disconnect"
:disabled="!isConnected"
class="px-4 py-2 bg-red-500 text-white rounded disabled:opacity-50"
>
断开
</button>
<button
@click="clearLogs"
class="px-4 py-2 bg-gray-500 text-white rounded"
>
清空日志
</button>
</div>
<!-- 发送消息 -->
<div class="mb-6">
<h2 class="text-lg font-semibold mb-2">发送消息</h2>
<div class="flex space-x-2">
<input
v-model="messageInput"
@keyup.enter="sendMessage"
placeholder="输入测试消息..."
class="flex-1 px-3 py-2 border rounded"
:disabled="!isConnected"
>
<button
@click="sendMessage"
:disabled="!isConnected || !messageInput.trim()"
class="px-4 py-2 bg-green-500 text-white rounded disabled:opacity-50"
>
发送
</button>
</div>
</div>
<!-- 日志 -->
<div class="mb-6">
<h2 class="text-lg font-semibold mb-2">连接日志</h2>
<div class="bg-gray-100 p-4 rounded-lg h-64 overflow-y-auto">
<div v-for="(log, index) in logs" :key="index" class="mb-1 text-sm">
<span class="text-gray-500">{{ log.timestamp }}</span>
<span :class="log.type === 'error' ? 'text-red-600' : log.type === 'success' ? 'text-green-600' : 'text-blue-600'">
[{{ log.type.toUpperCase() }}]
</span>
{{ log.message }}
</div>
</div>
</div>
<!-- 接收到的消息 -->
<div>
<h2 class="text-lg font-semibold mb-2">接收到的消息</h2>
<div class="bg-gray-100 p-4 rounded-lg h-64 overflow-y-auto">
<div v-for="(message, index) in receivedMessages" :key="index" class="mb-2 p-2 bg-white rounded">
<div class="text-xs text-gray-500 mb-1">{{ message.timestamp }}</div>
<pre class="text-sm">{{ JSON.stringify(message.data, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { stompWebSocketService as webSocketService, type WebSocketMessage } from '@/services/stomp-websocket'
import { envConfig } from '@/config/env'
// 响应式数据
const connectionStatus = ref('DISCONNECTED')
const isConnected = computed(() => connectionStatus.value === 'CONNECTED')
const messageInput = ref('')
const logs = ref<Array<{timestamp: string, type: string, message: string}>>([])
const receivedMessages = ref<Array<{timestamp: string, data: any}>>([])
// 配置
const wsUrl = `${envConfig.apiBaseUrl.replace('http', 'ws').replace('/api', '')}/ws/chat`
const userId = ref(`test_user_${Date.now()}`)
// 计算样式类
const statusClass = computed(() => {
switch (connectionStatus.value) {
case 'CONNECTED':
return 'bg-green-100 border border-green-300'
case 'CONNECTING':
return 'bg-yellow-100 border border-yellow-300'
case 'ERROR':
return 'bg-red-100 border border-red-300'
default:
return 'bg-gray-100 border border-gray-300'
}
})
// 添加日志
const addLog = (type: string, message: string) => {
logs.value.push({
timestamp: new Date().toLocaleTimeString(),
type,
message
})
}
// 连接WebSocket
const connect = async () => {
try {
addLog('info', '开始连接WebSocket...')
await webSocketService.connect(userId.value, {
onConnect: () => {
addLog('success', 'WebSocket连接成功')
connectionStatus.value = 'CONNECTED'
},
onDisconnect: () => {
addLog('info', 'WebSocket连接断开')
connectionStatus.value = 'DISCONNECTED'
},
onError: (error) => {
addLog('error', `WebSocket错误: ${error.userMessage || error.message || '未知错误'}`)
connectionStatus.value = 'ERROR'
},
onStatusChange: (status) => {
connectionStatus.value = status
addLog('info', `连接状态变更: ${status}`)
},
onMessage: (message: WebSocketMessage) => {
addLog('success', `收到消息: ${message.type} - ${message.content}`)
receivedMessages.value.push({
timestamp: new Date().toLocaleTimeString(),
data: message
})
}
})
} catch (error) {
addLog('error', `连接失败: ${error}`)
}
}
// 断开连接
const disconnect = () => {
webSocketService.disconnect()
addLog('info', '主动断开连接')
}
// 发送消息
const sendMessage = () => {
if (!messageInput.value.trim()) return
try {
webSocketService.sendChatMessage(messageInput.value.trim())
addLog('info', `发送消息: ${messageInput.value.trim()}`)
messageInput.value = ''
} catch (error) {
addLog('error', `发送消息失败: ${error}`)
}
}
// 清空日志
const clearLogs = () => {
logs.value = []
receivedMessages.value = []
}
// 生命周期
onMounted(() => {
addLog('info', 'WebSocket测试页面已加载')
addLog('info', `WebSocket URL: ${wsUrl}`)
})
onUnmounted(() => {
if (isConnected.value) {
disconnect()
}
})
</script>
+145
View File
@@ -0,0 +1,145 @@
<template>
<div class="debug-page p-8">
<h1 class="text-2xl font-bold mb-6">环境变量调试页面</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 当前环境信息 -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold mb-4">当前环境信息</h2>
<div class="space-y-2">
<div><strong>环境类型:</strong> {{ currentEnv }}</div>
<div><strong>环境名称:</strong> {{ envConfig.name }}</div>
<div><strong>调试模式:</strong> {{ envConfig.debug ? '开启' : '关闭' }}</div>
<div><strong>Mock模式:</strong> {{ envConfig.mock ? '开启' : '关闭' }}</div>
</div>
</div>
<!-- API配置信息 -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold mb-4">API配置信息</h2>
<div class="space-y-2">
<div><strong>API基础URL:</strong> {{ envConfig.apiBaseUrl }}</div>
<div><strong>WebSocket URL:</strong> {{ envConfig.wsBaseUrl }}</div>
<div><strong>上传URL:</strong> {{ envConfig.uploadUrl }}</div>
</div>
</div>
<!-- 原始环境变量 -->
<div class="bg-white rounded-lg shadow p-6 md:col-span-2">
<h2 class="text-lg font-semibold mb-4">原始环境变量</h2>
<pre class="bg-gray-100 p-4 rounded text-sm overflow-auto">{{ JSON.stringify(rawEnv, null, 2) }}</pre>
</div>
<!-- API测试 -->
<div class="bg-white rounded-lg shadow p-6 md:col-span-2">
<h2 class="text-lg font-semibold mb-4">API连接测试</h2>
<div class="space-y-4">
<div class="flex space-x-4">
<button
@click="testApiConnection"
:disabled="testing"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
>
{{ testing ? '测试中...' : '测试API连接' }}
</button>
<router-link
to="/debug/websocket"
class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
>
WebSocket测试
</router-link>
<router-link
to="/chat"
class="bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600"
>
聊天页面
</router-link>
</div>
<div v-if="testResult" class="mt-4">
<h3 class="font-semibold mb-2">测试结果:</h3>
<pre class="bg-gray-100 p-4 rounded text-sm overflow-auto">{{ testResult }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { envConfig, getCurrentEnv } from '@/config/env'
import AuthService from '@/services/auth'
// 响应式数据
const testing = ref(false)
const testResult = ref('')
// 计算属性
const currentEnv = computed(() => getCurrentEnv())
const rawEnv = computed(() => import.meta.env)
/**
* 测试API连接
*/
const testApiConnection = async () => {
testing.value = true
testResult.value = ''
try {
console.log('开始测试API连接...')
console.log('使用的API基础URL:', envConfig.apiBaseUrl)
const response = await AuthService.getCaptcha()
testResult.value = JSON.stringify({
success: true,
message: 'API连接成功',
apiUrl: envConfig.apiBaseUrl,
response: {
captchaKey: response.captchaKey,
imageLength: response.captchaImage?.length || 0,
expiresIn: response.expiresIn,
imagePreview: response.captchaImage?.substring(0, 100) + '...' // 显示前100个字符
}
}, null, 2)
} catch (error: any) {
console.error('API连接测试失败:', error)
testResult.value = JSON.stringify({
success: false,
message: 'API连接失败',
apiUrl: envConfig.apiBaseUrl,
error: {
message: error.message,
status: error.status,
code: error.code,
config: error.config ? {
url: error.config.url,
method: error.config.method,
baseURL: error.config.baseURL
} : null
}
}, null, 2)
} finally {
testing.value = false
}
}
// 组件挂载时输出调试信息
onMounted(() => {
console.log('=== 环境变量调试信息 ===')
console.log('当前环境:', currentEnv.value)
console.log('环境配置:', envConfig)
console.log('原始环境变量:', import.meta.env)
console.log('========================')
})
</script>
<style scoped>
.debug-page {
min-height: 100vh;
background-color: #f5f5f5;
}
</style>
+261 -673
View File
@@ -1,703 +1,291 @@
<template>
<div class="diary-page">
<!-- 头部 -->
<header class="page-header">
<div class="header-content">
<div class="header-left">
<a-button type="text" @click="$router.back()" class="back-btn">
<ArrowLeftOutlined />
</a-button>
<h1 class="page-title">情绪记录</h1>
<div class="bg-light-gray font-sans text-text-dark flex flex-col h-screen antialiased">
<!-- Header -->
<header class="bg-white shadow-md z-10 flex-shrink-0">
<div class="container mx-auto px-4 py-3 flex items-center justify-between relative">
<div class="flex items-center space-x-4">
<router-link to="/" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="home" class="w-6 h-6"></i>
</router-link>
<router-link to="/messages" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="bell" class="w-6 h-6"></i>
</router-link>
</div>
<a-button type="primary" @click="$router.push('/chat')" class="new-entry-btn">
<HeartOutlined />
生成情绪记录
</a-button>
<h1 class="text-lg font-bold text-text-dark absolute left-1/2 -translate-x-1/2">日记</h1>
<router-link to="/settings" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="user" class="w-6 h-6"></i>
</router-link>
</div>
</header>
<!-- 主要内容 -->
<main class="page-main">
<div class="container">
<!-- 提示卡片 -->
<div class="tip-section">
<a-card class="tip-card">
<div class="tip-content">
<HeartOutlined class="tip-icon" />
<div class="tip-text">
<h3>如何生成情绪记录</h3>
<p>与开开聊天后点击聊天页面右上角的 按钮AI会分析你的聊天内容并生成情绪记录</p>
</div>
<a-button type="primary" @click="$router.push('/chat')" class="tip-btn">
去聊天
</a-button>
</div>
</a-card>
</div>
<!-- 情绪记录列表 -->
<div class="emotion-feed">
<div
v-for="record in emotionRecords"
:key="record.id"
class="emotion-entry"
<!-- Main Content -->
<main class="flex-1 overflow-y-auto p-4 lg:p-6 pb-24">
<!-- New Post Form -->
<div id="new-post-section" class="bg-white p-4 rounded-xl shadow-sm mb-6 scroll-mt-20">
<h2 class="font-bold text-text-dark mb-3">发布新日记</h2>
<textarea
id="new-diary-content"
v-model="newDiaryContent"
class="w-full h-24 p-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-tech-blue/50 focus:border-tech-blue outline-none transition"
placeholder="今天有什么新鲜事或心里话想对开开说?"
></textarea>
<div class="mt-3 flex justify-end">
<button
@click="publishDiaryHandler"
:disabled="publishDisabled"
class="bg-tech-blue text-white px-5 py-2 rounded-full font-semibold hover:bg-blue-600 transition-all duration-300 transform hover:scale-105 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<a-card class="entry-card">
<div class="entry-header">
<div class="entry-meta">
<span class="emotion-icon">
{{ getEmotionIcon(record.emotionType) }}
</span>
<div class="emotion-info">
<span class="emotion-type">{{ record.emotionType }}</span>
<span class="emotion-date">
{{ formatTime.friendly(record.createTime) }}
</span>
</div>
</div>
<a-dropdown>
<a-button type="text" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="deleteEmotionRecord(record.id)" danger>
<DeleteOutlined />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
发布
</button>
</div>
</div>
<!-- Diary Feed -->
<div id="diary-feed" class="space-y-4">
<!-- 加载状态 -->
<div v-if="loading" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-tech-blue"></div>
<p class="mt-2 text-text-medium">加载中...</p>
</div>
<!-- 空状态 -->
<div v-else-if="diaries.length === 0" class="text-center py-12">
<i data-lucide="book-open" class="w-16 h-16 text-gray-300 mx-auto mb-4"></i>
<p class="text-text-medium text-lg mb-2">还没有日记</p>
<p class="text-text-medium text-sm">写下你的第一篇日记开开会给你温暖的回应</p>
</div>
<!-- 日记列表 -->
<div
v-for="entry in diaries"
:key="entry.id"
class="bg-white rounded-xl shadow-sm p-4 animate-fade-in-up"
>
<!-- 用户日记内容 -->
<div class="flex items-center mb-4">
<i
data-lucide="user-circle-2"
class="w-10 h-10 text-gray-400"
></i>
<div class="ml-3">
<p class="font-semibold text-text-dark"></p>
<p class="text-xs text-text-medium">{{ formatTime(entry.publishTime) }}</p>
</div>
</div>
<p class="text-text-dark whitespace-pre-wrap leading-relaxed">{{ entry.content }}</p>
<!-- AI评论 -->
<div v-if="entry.aiComment" class="mt-4 pt-3 border-t border-gray-100">
<div class="flex items-start">
<img
src="https://r2.flowith.net/files/o/1752574572161-kaikai_character_energetic_animation_index_2@1024x1024.png"
alt="开开"
class="w-8 h-8 rounded-full object-cover flex-shrink-0"
>
<div class="ml-3 bg-light-gray p-3 rounded-lg w-full">
<p class="text-sm font-semibold text-text-dark">开开</p>
<p class="text-sm text-text-dark mt-1">{{ entry.aiComment }}</p>
<p class="text-xs text-text-medium mt-2">{{ formatTime(entry.aiCommentTime) }}</p>
</div>
<div class="entry-content">
<!-- 情绪强度 -->
<div class="emotion-intensity">
<span class="intensity-label">情绪强度:</span>
<a-progress
:percent="Math.round((record.intensity || 0) * 100)"
:stroke-color="getIntensityColor(record.intensity || 0)"
:show-info="true"
size="small"
class="intensity-bar"
/>
</div>
<!-- 触发因素 -->
<div v-if="record.triggers" class="emotion-triggers">
<span class="triggers-label">触发因素:</span>
<span class="triggers-text">{{ record.triggers }}</span>
</div>
<!-- 描述 -->
<div v-if="record.description" class="emotion-description">
<p class="description-text">{{ record.description }}</p>
</div>
<!-- 标签 -->
<div class="emotion-tags" v-if="record.tags">
<a-tag
v-for="tag in (typeof record.tags === 'string' ? record.tags.split(',') : record.tags)"
:key="tag"
color="blue"
class="emotion-tag"
>
{{ tag.trim() }}
</a-tag>
</div>
<!-- 其他信息 -->
<div class="emotion-details">
<div v-if="record.weather" class="detail-item">
<span class="detail-label">天气:</span>
<span class="detail-value">{{ record.weather }}</span>
</div>
<div v-if="record.location" class="detail-item">
<span class="detail-label">地点:</span>
<span class="detail-value">{{ record.location }}</span>
</div>
<div v-if="record.activity" class="detail-item">
<span class="detail-label">活动:</span>
<span class="detail-value">{{ record.activity }}</span>
</div>
</div>
</div>
</a-card>
</div>
</div>
<!-- 加载更多按钮 -->
<div v-if="pagination.current * pagination.pageSize < pagination.total" class="load-more">
<a-button
type="dashed"
block
@click="loadMoreRecords"
:loading="loading"
size="large"
>
加载更多 ({{ emotionRecords.length }}/{{ pagination.total }})
</a-button>
</div>
<!-- 没有更多数据提示 -->
<div v-else-if="emotionRecords.length > 0" class="no-more">
<a-divider>已显示全部记录</a-divider>
</div>
<!-- 空状态 -->
<div v-if="emotionRecords.length === 0 && !loading" class="empty-state">
<a-empty
description="还没有情绪记录"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
>
<p class="empty-tip">
与开开聊天后点击右上角的 <HeartOutlined /> 按钮生成情绪记录
</p>
</a-empty>
</div>
<!-- 加载状态 -->
<div v-if="loading && emotionRecords.length === 0" class="loading-state">
<a-spin size="large" tip="加载中..." />
<!-- 互动按钮 -->
<div class="mt-4 pt-3 border-t border-gray-100 flex items-center justify-between">
<div class="flex items-center space-x-4">
<button class="flex items-center text-sm text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="heart" class="w-4 h-4 mr-1.5"></i>
<span>{{ entry.likeCount || 0 }}</span>
</button>
<button class="flex items-center text-sm text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="message-square" class="w-4 h-4 mr-1.5"></i>
<span>{{ entry.commentCount || 0 }}</span>
</button>
<button class="flex items-center text-sm text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="eye" class="w-4 h-4 mr-1.5"></i>
<span>{{ entry.viewCount || 0 }}</span>
</button>
</div>
<button class="flex items-center text-sm text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="share" class="w-4 h-4 mr-1.5"></i>
<span>分享</span>
</button>
</div>
</div>
</div>
<div class="flex justify-center mt-6">
<el-pagination
v-if="total > pageSize"
v-model:current-page="page"
:page-size="pageSize"
layout="prev, pager, next"
:total="total"
@current-change="onPageChange"
/>
</div>
</main>
<!-- App Navigation -->
<BottomNavigation />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
ArrowLeftOutlined,
// PlusOutlined,
MoreOutlined,
// EditOutlined,
DeleteOutlined,
HeartOutlined,
} from '@ant-design/icons-vue'
import { Empty, message } from 'ant-design-vue'
import { useDiaryStore } from '@/stores'
import { formatTime } from '@/utils'
import { emotionRecordApi } from '@/services/api'
// import type { DiaryEntry } from '@/types'
import { ref, onMounted } from 'vue'
import BottomNavigation from '@/components/layout/BottomNavigation.vue'
import { publishDiary, getUserDiaries } from '@/services/diary'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const diaryStore = useDiaryStore()
console.log('diaryStore initialized:', diaryStore) // 避免未使用警告
const authStore = useAuthStore()
const userId = authStore.userId
// 响应式数据
const showNewEntryModal = ref(false)
console.log('showNewEntryModal initialized:', showNewEntryModal) // 避免未使用警告
const newEntryContent = ref('')
const selectedMood = ref<string>('neutral')
const selectedTags = ref<string[]>([])
const newTagInput = ref('')
const emotionRecords = ref<any[]>([])
const loading = ref(false)
const pagination = ref({
current: 1,
pageSize: 10,
total: 0
})
// 开开头像
// const kaikaiAvatar = 'https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png'
const newDiaryContent = ref('')
const publishDisabled = ref(false)
const loading = ref(false)
const diaries = ref<any[]>([])
const page = ref(1)
const pageSize = 10
const total = ref(0)
// 心情表情映射
// const moodEmojis = {
// happy: '😊',
// sad: '😢',
// neutral: '😐',
// excited: '🤩',
// tired: '😴'
// }
// 方法
// const getMoodEmoji = (mood: string) => {
// return moodEmojis[mood as keyof typeof moodEmojis] || '😐'
// }
// const publishEntry = async () => {
// if (!newEntryContent.value.trim()) {
// message.warning('请输入日记内容')
// return
// }
// try {
// await diaryStore.addEntry(
// newEntryContent.value.trim(),
// selectedMood.value,
// selectedTags.value
// )
// message.success('日记发布成功!')
// resetNewEntry()
// showNewEntryModal.value = false
// } catch (error) {
// message.error('发布失败,请重试')
// }
// }
const resetNewEntry = () => {
newEntryContent.value = ''
selectedMood.value = 'neutral'
selectedTags.value = []
newTagInput.value = ''
}
console.log('resetNewEntry function defined:', resetNewEntry) // 避免未使用警告
// const addTag = () => {
// const tag = newTagInput.value.trim()
// if (tag && !selectedTags.value.includes(tag)) {
// selectedTags.value.push(tag)
// newTagInput.value = ''
// }
// }
// const removeTag = (tag: string) => {
// const index = selectedTags.value.indexOf(tag)
// if (index > -1) {
// selectedTags.value.splice(index, 1)
// }
// }
// const editEntry = (_entry: DiaryEntry) => {
// // TODO: 实现编辑功能
// message.info('编辑功能开发中...')
// }
// const deleteEntry = async (id: string) => {
// try {
// await diaryStore.deleteEntry(id)
// message.success('日记删除成功')
// } catch (error) {
// message.error('删除失败,请重试')
// }
// }
// 加载情绪记录
const loadEmotionRecords = async (page = 1, append = false) => {
if (loading.value) return
try {
loading.value = true
// 调用API获取用户情绪记录(后端会从token中获取用户信息)
const pageData = await emotionRecordApi.getUserEmotionRecords(page, pagination.value.pageSize)
if (append) {
emotionRecords.value.push(...(pageData.records || []))
} else {
emotionRecords.value = pageData.records || []
}
pagination.value = {
current: pageData.current || 1,
pageSize: pageData.size || 10,
total: pageData.total || 0
}
console.log('情绪记录加载成功:', emotionRecords.value.length, '条')
} catch (error) {
console.error('加载情绪记录时发生错误:', error)
message.error('加载情绪记录失败,请检查网络连接')
} finally {
loading.value = false
const fetchDiaries = async () => {
if (!userId) return
console.log('🔍 开始获取日记数据,用户ID:', userId)
loading.value = true
try {
const res = await getUserDiaries(userId, page.value, pageSize)
console.log('🔍 API返回的日记数据:', res)
if (res?.data?.records) {
diaries.value = res.data.records
total.value = res.data.total
console.log('🔍 设置日记数据:', diaries.value)
} else {
console.log('🔍 API返回数据格式不正确:', res)
}
} catch (error) {
console.error('🔍 获取日记数据失败:', error)
} finally {
loading.value = false
}
}
// 格式化时间显示
const formatTime = (timeStr: string) => {
if (!timeStr) return ''
try {
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(diff / (1000 * 60 * 60))
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch (error) {
console.error('格式化时间失败:', error)
return timeStr
}
}
const publishDiaryHandler = async () => {
const content = newDiaryContent.value.trim()
if (!content) return
publishDisabled.value = true
try {
await publishDiary({
userId,
title: '',
content,
images: [],
videos: [],
location: '',
weather: '',
mood: '',
moodScore: null,
tags: [],
isPublic: 1,
isAnonymous: 0
})
ElMessage.success('发布成功')
newDiaryContent.value = ''
fetchDiaries()
} finally {
setTimeout(() => { publishDisabled.value = false }, 2000)
}
}
const onPageChange = (val: number) => {
page.value = val
fetchDiaries()
}
onMounted(fetchDiaries)
// 生命周期
onMounted(() => {
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
// 加载更多情绪记录
const loadMoreRecords = () => {
if (pagination.value.current * pagination.value.pageSize < pagination.value.total) {
loadEmotionRecords(pagination.value.current + 1, true)
// 延迟初始化图标
setTimeout(() => {
if (window.lucide) {
window.lucide.createIcons()
}
}
// 删除情绪记录
const deleteEmotionRecord = async (id: string) => {
try {
await emotionRecordApi.deleteEmotionRecord(id)
message.success('情绪记录删除成功')
// 重新加载第一页
await loadEmotionRecords(1)
} catch (error) {
console.error('删除情绪记录时发生错误:', error)
message.error('删除失败,请重试')
}
}
// 获取情绪图标
const getEmotionIcon = (emotionType: string) => {
const emotionIcons: Record<string, string> = {
'joy': '😊',
'happiness': '😊',
'happy': '😊',
'excited': '🤩',
'love': '😍',
'sadness': '😢',
'sad': '😢',
'crying': '😭',
'anger': '😠',
'angry': '😡',
'rage': '🤬',
'fear': '😨',
'scared': '😰',
'anxiety': '😰',
'surprise': '😲',
'shocked': '😱',
'neutral': '😐',
'calm': '😌',
'peaceful': '😌',
'tired': '😴',
'exhausted': '😵',
'confused': '😕',
'disappointed': '😞',
'frustrated': '😤',
'bored': '😑',
'content': '😊',
'grateful': '🙏',
'hopeful': '🌟',
'proud': '😎',
'embarrassed': '😳',
'guilty': '😔',
'lonely': '😞',
'nostalgic': '🥺',
'optimistic': '😄',
'pessimistic': '😟'
}
return emotionIcons[emotionType.toLowerCase()] || '😐'
}
// 获取情绪强度颜色
const getIntensityColor = (intensity: number) => {
if (intensity >= 0.8) return '#ff4d4f' // 高强度 - 红色
if (intensity >= 0.6) return '#ff7a45' // 中高强度 - 橙红色
if (intensity >= 0.4) return '#ffa940' // 中等强度 - 橙色
if (intensity >= 0.2) return '#52c41a' // 低强度 - 绿色
return '#1890ff' // 很低强度 - 蓝色
}
// 组件挂载
onMounted(() => {
loadEmotionRecords(1)
})
}, 100)
})
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.diary-page {
min-height: 100vh;
background: $light-gray;
<style scoped>
/* 导入原始样式变量 */
:root {
--tech-blue: #4A90E2;
--warm-orange: #F5A623;
--white: #FFFFFF;
--light-gray: #F7F8FA;
--text-dark: #333333;
--text-medium: #888888;
}
/* 应用原始样式类 */
.bg-tech-blue { background-color: var(--tech-blue); }
.bg-warm-orange { background-color: var(--warm-orange); }
.bg-light-gray { background-color: var(--light-gray); }
.text-tech-blue { color: var(--tech-blue); }
.text-text-dark { color: var(--text-dark); }
.text-text-medium { color: var(--text-medium); }
.border-tech-blue { border-color: var(--tech-blue); }
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out forwards;
opacity: 0;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
.page-header {
background: white;
box-shadow: $shadow-sm;
position: sticky;
top: 0;
z-index: 10;
to {
opacity: 1;
transform: translateY(0);
}
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
max-width: 800px;
margin: 0 auto;
}
.header-left {
display: flex;
align-items: center;
gap: $spacing-md;
}
.back-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
.page-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-dark;
margin: 0;
}
.new-entry-btn {
border-radius: $border-radius-full;
}
.page-main {
padding: $spacing-lg;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.tip-section {
margin-bottom: $spacing-xl;
}
.tip-card {
.tip-content {
display: flex;
align-items: center;
gap: $spacing-md;
.tip-icon {
font-size: 2rem;
color: #ff6b6b;
}
.tip-text {
flex: 1;
h3 {
margin: 0 0 $spacing-xs 0;
color: $text-dark;
font-size: $font-size-md;
}
p {
margin: 0;
color: $text-medium;
font-size: $font-size-sm;
}
}
.tip-btn {
border-radius: $border-radius-full;
}
}
}
.emotion-feed {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.emotion-entry {
.entry-card {
transition: all $transition-normal;
&:hover {
box-shadow: $shadow-md;
}
}
.entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
}
.entry-meta {
display: flex;
align-items: center;
gap: $spacing-md;
}
.emotion-icon {
font-size: 2rem;
}
.emotion-info {
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.emotion-type {
font-weight: $font-weight-medium;
color: $text-dark;
font-size: $font-size-md;
text-transform: capitalize;
}
.emotion-date {
color: $text-medium;
font-size: $font-size-sm;
}
.entry-content {
margin-bottom: $spacing-md;
}
.emotion-intensity {
display: flex;
align-items: center;
gap: $spacing-sm;
margin-bottom: $spacing-md;
.intensity-label {
font-weight: $font-weight-medium;
color: $text-dark;
min-width: 80px;
}
.intensity-bar {
flex: 1;
}
}
.emotion-triggers {
margin-bottom: $spacing-md;
.triggers-label {
font-weight: $font-weight-medium;
color: $text-dark;
margin-right: $spacing-sm;
}
.triggers-text {
color: $text-medium;
}
}
.emotion-description {
margin-bottom: $spacing-md;
.description-text {
line-height: 1.6;
color: $text-dark;
margin: 0;
padding: $spacing-sm;
background: $light-gray;
border-radius: $border-radius-md;
}
}
.emotion-tags {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
margin-bottom: $spacing-md;
}
.emotion-details {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
.detail-item {
display: flex;
align-items: center;
gap: $spacing-xs;
.detail-label {
font-weight: $font-weight-medium;
color: $text-dark;
font-size: $font-size-sm;
}
.detail-value {
color: $text-medium;
font-size: $font-size-sm;
}
}
}
}
.load-more {
margin-top: $spacing-lg;
}
.no-more {
margin-top: $spacing-lg;
text-align: center;
}
.ai-reply {
display: flex;
gap: $spacing-sm;
padding: $spacing-md;
background: rgba(74, 144, 226, 0.05);
border-radius: $border-radius-md;
border-left: 3px solid $tech-blue;
.ai-content {
flex: 1;
}
.ai-name {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $tech-blue;
margin-bottom: $spacing-xs;
}
.ai-text {
color: $text-dark;
line-height: 1.5;
margin: 0;
}
}
.empty-state,
.loading-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: $spacing-xxl;
text-align: center;
.empty-tip {
margin-top: $spacing-md;
color: $text-medium;
font-size: $font-size-sm;
line-height: 1.5;
}
}
// 模态框样式
.modal-content {
.modal-textarea {
margin-bottom: $spacing-lg;
}
.modal-options {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.mood-selector,
.tags-input {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.mood-label,
.tags-label {
font-weight: $font-weight-medium;
color: $text-dark;
min-width: 60px;
}
.tag-input {
max-width: 200px;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
margin-top: $spacing-xs;
}
}
</style>
/* 全局样式 */
body {
font-family: 'Noto Sans SC', sans-serif;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="emotion-page">
<div class="container mx-auto px-4 py-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">情绪管理</h1>
<p class="text-gray-600">记录和管理你的情绪变化</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 情绪记录表单 -->
<div class="lg:col-span-1">
<div class="card">
<h2 class="text-xl font-semibold mb-4">记录今日情绪</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
情绪评分
</label>
<el-rate v-model="emotionScore" :max="10" show-score />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
情绪类型
</label>
<el-select v-model="emotionType" placeholder="选择情绪类型" class="w-full">
<el-option label="开心" value="happy" />
<el-option label="平静" value="calm" />
<el-option label="兴奋" value="excited" />
<el-option label="焦虑" value="anxious" />
<el-option label="悲伤" value="sad" />
<el-option label="愤怒" value="angry" />
</el-select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
情绪描述
</label>
<el-input
v-model="emotionDescription"
type="textarea"
:rows="4"
placeholder="描述一下你今天的情绪..."
/>
</div>
<el-button type="primary" class="w-full" @click="saveEmotion">
保存记录
</el-button>
</div>
</div>
</div>
<!-- 情绪日历 -->
<div class="lg:col-span-2">
<div class="card">
<h2 class="text-xl font-semibold mb-4">情绪日历</h2>
<div class="text-center text-gray-500 py-12">
<el-icon class="text-4xl mb-4">
<Calendar />
</el-icon>
<p>情绪日历功能开发中...</p>
</div>
</div>
</div>
</div>
<!-- 情绪趋势图 -->
<div class="mt-8">
<div class="card">
<h2 class="text-xl font-semibold mb-4">情绪趋势</h2>
<div class="text-center text-gray-500 py-12">
<el-icon class="text-4xl mb-4">
<TrendCharts />
</el-icon>
<p>情绪趋势图表功能开发中...</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Calendar, TrendCharts } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const emotionScore = ref(5)
const emotionType = ref('')
const emotionDescription = ref('')
const saveEmotion = () => {
// TODO: 实现保存情绪记录的逻辑
ElMessage.success('情绪记录已保存')
}
</script>
<style scoped>
.emotion-page {
min-height: 100vh;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
background-attachment: fixed;
}
.emotion-page .container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
margin-top: 2rem;
margin-bottom: 2rem;
}
</style>
+447 -398
View File
@@ -1,432 +1,415 @@
<template>
<div class="home-page">
<!-- 头部导航 -->
<AppHeader />
<!-- Header -->
<header
id="main-header"
class="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-lg transition-all duration-300"
:class="{ 'scrolled': isScrolled }"
>
<div class="container mx-auto px-6 py-4 flex justify-between items-center">
<router-link to="/" class="flex items-center space-x-2">
<svg width="32" height="32" viewBox="0 0 100 100" class="text-tech-blue">
<path fill="currentColor" d="M85.4,37.3C85.4,37.3,85.4,37.3,85.4,37.3c-2.8-9.9-10-17.7-19.1-21.2c-0.2-0.1-0.5-0.1-0.7-0.2c-0.1,0-0.2-0.1-0.3-0.1 c-1.2-0.4-2.5-0.8-3.7-1.1c-1-0.2-2-0.4-3-0.6c-1.1-0.2-2.1-0.3-3.2-0.4c-1.2-0.1-2.4-0.2-3.6-0.2c-0.1,0-0.2,0-0.3,0h-0.1 c-0.1,0-0.2,0-0.3,0c-1.2,0-2.4,0.1-3.6,0.2c-1.1,0.1-2.1,0.2-3.2,0.4c-1,0.2-2,0.4-3,0.6c-1.3,0.3-2.5,0.6-3.7,1.1 c-0.1,0-0.2,0.1-0.3,0.1c-0.2,0.1-0.5,0.1-0.7,0.2C21.6,19.6,14.4,27.4,11.6,37.3c0,0,0,0.1-0.1,0.1C8,47.7,8,58.8,11.5,69.2 c0,0.1,0.1,0.1,0.1,0.2c2.8,9.9,10,17.7,19.1,21.2c0.2,0.1,0.5,0.1,0.7,0.2c0.1,0,0.2,0.1,0.3,0.1c1.2,0.4,2.5,0.8,3.7,1.1 c1,0.2,2,0.4,3,0.6c1.1,0.2,2.1,0.3,3.2,0.4c1.2,0.1,2.4,0.2,3.6,0.2c0.1,0,0.2,0,0.3,0h0.1c0.1,0,0.2,0,0.3,0 c1.2,0,2.4-0.1,3.6-0.2c-1.1-0.1-2.1-0.2-3.2-0.4c1-0.2,2-0.4,3-0.6c1.3-0.3,2.5-0.6,3.7-1.1c0.1,0,0.2-0.1,0.3-0.1 c0.2-0.1,0.5-0.1,0.7-0.2c9.1-3.5,16.3-11.3,19.1-21.2c0-0.1,0.1-0.1,0.1-0.2C89,58.8,89,47.7,85.4,37.3z M50,77.9 c-15.4,0-27.9-12.5-27.9-27.9S34.6,22.1,50,22.1s27.9,12.5,27.9,27.9S65.4,77.9,50,77.9z"></path>
<path fill="var(--warm-orange)" d="M50,88.8c-21.4,0-38.8-17.4-38.8-38.8S28.6,11.2,50,11.2s38.8,17.4,38.8,38.8S71.4,88.8,50,88.8z M50,16.2 c-18.7,0-33.8,15.1-33.8,33.8S31.3,83.8,50,83.8s33.8-15.1,33.8-33.8S68.7,16.2,50,16.2z"></path>
</svg>
<span class="text-2xl font-bold text-tech-blue">开心APP</span>
</router-link>
<nav class="hidden lg:flex items-center space-x-8" id="nav-menu">
</nav>
<div class="flex items-center space-x-4">
<!-- 未登录状态 -->
<template v-if="!authStore.isLoggedIn">
<router-link
to="/login"
class="hidden sm:inline-block text-text-medium hover:text-tech-blue transition-colors"
>
登录
</router-link>
<router-link
to="/register"
class="hidden sm:inline-block text-text-medium hover:text-tech-blue transition-colors"
>
注册
</router-link>
<router-link
to="/chat"
class="bg-tech-blue text-white px-5 py-2.5 rounded-full font-semibold hover:bg-blue-600 transition-all duration-300 transform hover:scale-105 shadow-lg shadow-blue-500/20"
>
免费开始
</router-link>
</template>
<!-- Hero Section -->
<section class="hero-section">
<div class="wave-background">
<div class="wave"></div>
<div class="wave"></div>
<div class="wave"></div>
</div>
<div class="hero-content">
<div class="hero-text">
<h1 class="hero-title animate-fade-in-up">
你好我是<span class="highlight">开开</span>
</h1>
<p class="hero-subtitle animate-fade-in-up delay-300">
你的情绪陪伴使者
</p>
</div>
<div class="hero-image animate-fade-in-up delay-500">
<img
src="https://r2.flowith.net/files/1517c93c-849d-4a9b-94b6-d61aa295a8a1/1752600429516-image-1752600425876-cnlfpkbrh@1024x1024.png"
alt="欢迎姿态的开开"
class="kaikai-image"
/>
</div>
<div class="hero-action animate-fade-in-up delay-700">
<a-button
type="primary"
size="large"
@click="$router.push('/chat')"
class="start-chat-btn"
<!-- 已登录状态 -->
<template v-else>
<router-link
to="/chat"
class="hidden sm:inline-block bg-tech-blue text-white px-4 py-2 rounded-full font-medium hover:bg-blue-600 transition-all duration-300"
>
开始对话
</router-link>
<UserDropdown />
</template>
<button
@click="toggleMobileMenu"
class="lg:hidden text-text-dark"
>
开始一段对话
</a-button>
<i data-lucide="menu" class="w-6 h-6"></i>
</button>
</div>
</div>
</section>
</header>
<!-- Features Section -->
<section class="features-section">
<div class="container">
<div class="features-header">
<h2 class="features-title scroll-target">核心功能</h2>
<p class="features-subtitle scroll-target">
开开博学多才可爱治愈愿意用最温柔的方式陪伴每一个需要倾听的生命
</p>
</div>
<div class="features-grid">
<div
v-for="(feature, index) in features"
:key="feature.title"
class="feature-card scroll-target"
:style="{ animationDelay: `${index * 100}ms` }"
<!-- Mobile Menu -->
<div
id="mobile-menu"
class="fixed inset-0 bg-white/90 backdrop-blur-xl z-40 p-8 lg:hidden transition-all duration-300"
:class="{ 'hidden': !mobileMenuOpen }"
>
<div class="flex justify-end mb-8">
<button @click="toggleMobileMenu" class="text-text-dark">
<i data-lucide="x" class="w-6 h-6"></i>
</button>
</div>
<nav class="flex flex-col space-y-6 text-center" id="mobile-nav-menu">
<!-- 未登录状态 -->
<template v-if="!authStore.isLoggedIn">
<router-link
to="/login"
@click="toggleMobileMenu"
class="text-lg text-text-medium hover:text-tech-blue transition-colors py-2"
>
<div class="feature-image-container">
<img :src="feature.image" :alt="feature.alt" class="feature-image" />
登录
</router-link>
<router-link
to="/register"
@click="toggleMobileMenu"
class="text-lg text-text-medium hover:text-tech-blue transition-colors py-2"
>
注册
</router-link>
<router-link
to="/chat"
@click="toggleMobileMenu"
class="bg-tech-blue text-white px-6 py-3 rounded-full font-semibold mx-auto inline-block"
>
免费开始
</router-link>
</template>
<!-- 已登录状态 -->
<template v-else>
<div class="flex flex-col items-center space-y-4 mb-6">
<UserAvatar
:avatar="authStore.userInfo?.avatar"
:nickname="authStore.userInfo?.nickname || '用户'"
size="large"
/>
<div class="text-center">
<div class="text-lg font-semibold text-text-dark">{{ authStore.userInfo?.nickname || '用户' }}</div>
<div class="text-sm text-text-medium">{{ authStore.userInfo?.memberLevel || 'Lv.1' }}</div>
</div>
<div class="feature-content">
<div class="feature-header">
<component :is="feature.icon" class="feature-icon" />
<h3 class="feature-title">{{ feature.title }}</h3>
</div>
<router-link
to="/chat"
@click="toggleMobileMenu"
class="text-lg text-text-medium hover:text-tech-blue transition-colors py-2"
>
AI对话
</router-link>
<router-link
to="/profile"
@click="toggleMobileMenu"
class="text-lg text-text-medium hover:text-tech-blue transition-colors py-2"
>
个人中心
</router-link>
<router-link
to="/personal-dashboard"
@click="toggleMobileMenu"
class="text-lg text-text-medium hover:text-tech-blue transition-colors py-2"
>
个人仪表盘
</router-link>
<button
@click="handleMobileLogout"
class="text-lg text-red-500 hover:text-red-600 transition-colors py-2"
>
退出登录
</button>
</template>
</nav>
</div>
<main>
<!-- Hero Section -->
<section class="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden bg-white">
<div class="absolute inset-0 z-0 opacity-20">
<div class="wave"></div>
<div class="wave"></div>
<div class="wave"></div>
</div>
<div class="container mx-auto px-6 text-center relative z-10">
<div class="max-w-3xl mx-auto">
<h1 class="text-4xl md:text-6xl font-bold text-text-dark leading-tight mb-4 animate-fade-in-up" style="animation-delay: 0.1s;">
你好我是<span class="text-tech-blue">开开</span>
</h1>
<p class="text-xl md:text-2xl text-text-medium mb-8 animate-fade-in-up" style="animation-delay: 0.3s;">
你的情绪陪伴使者
</p>
</div>
<div class="mt-12 flex justify-center animate-fade-in-up" style="animation-delay: 0.5s;">
<img
src="https://r2.flowith.net/files/1517c93c-849d-4a9b-94b6-d61aa295a8a1/1752600429516-image-1752600425876-cnlfpkbrh@1024x1024.png"
alt="欢迎姿态的开开"
class="w-full max-w-sm h-auto drop-shadow-2xl"
style="object-fit: contain;"
>
</div>
<div class="mt-8">
<router-link
to="/chat"
class="bg-warm-orange text-white px-8 py-4 rounded-full font-bold text-lg hover:bg-orange-600 transition-all duration-300 transform hover:scale-105 inline-block shadow-lg shadow-orange-500/30 animate-fade-in-up"
style="animation-delay: 0.7s;"
>
开始一段对话
</router-link>
</div>
</div>
</section>
<!-- Features Section -->
<section class="py-20 lg:py-32 bg-light-gray">
<div class="container mx-auto px-6">
<div class="text-center max-w-3xl mx-auto mb-16">
<h2 class="text-3xl md:text-4xl font-bold text-text-dark mb-4 scroll-target">核心功能</h2>
<p class="text-lg text-text-medium scroll-target">
开开博学多才可爱治愈愿意用最温柔的方式陪伴每一个需要倾听的生命
</p>
</div>
<div id="features-grid" class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
<div
v-for="(feature, index) in features"
:key="index"
class="feature-card-bg rounded-2xl p-6 flex flex-col items-center text-center scroll-target"
:style="{ transitionDelay: `${index * 100}ms` }"
>
<div class="w-full aspect-square rounded-xl overflow-hidden mb-6 feature-card-image-container flex items-center justify-center">
<img
:src="feature.image"
:alt="feature.alt"
class="w-4/5 h-4/5 object-contain drop-shadow-lg"
>
</div>
<p class="feature-description">{{ feature.description }}</p>
<div class="flex items-center space-x-2 mb-3">
<i :data-lucide="feature.icon" class="w-5 h-5 text-tech-blue"></i>
<h3 class="text-xl font-bold text-text-dark">{{ feature.title }}</h3>
</div>
<p class="text-text-medium text-sm flex-grow">{{ feature.description }}</p>
</div>
</div>
</div>
</div>
</section>
</section>
</main>
<!-- 底部 -->
<AppFooter />
<!-- Footer -->
<footer class="bg-white">
<div class="container mx-auto px-6 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<div class="md:col-span-1">
<router-link to="/" class="flex items-center space-x-2">
<svg width="28" height="28" viewBox="0 0 100 100" class="text-tech-blue">
<path fill="currentColor" d="M85.4,37.3C85.4,37.3,85.4,37.3,85.4,37.3c-2.8-9.9-10-17.7-19.1-21.2c-0.2-0.1-0.5-0.1-0.7-0.2c-0.1,0-0.2-0.1-0.3-0.1 c-1.2-0.4-2.5-0.8-3.7-1.1c-1-0.2-2-0.4-3-0.6c-1.1-0.2-2.1-0.3-3.2-0.4c-1.2-0.1-2.4-0.2-3.6-0.2c-0.1,0-0.2,0-0.3,0h-0.1 c-0.1,0-0.2,0-0.3,0c-1.2,0-2.4,0.1-3.6,0.2c-1.1,0.1-2.1,0.2-3.2,0.4c-1,0.2-2,0.4-3,0.6c-1.3,0.3-2.5,0.6-3.7,1.1 c-0.1,0-0.2,0.1-0.3,0.1c-0.2,0.1-0.5,0.1-0.7,0.2C21.6,19.6,14.4,27.4,11.6,37.3c0,0,0,0.1-0.1,0.1C8,47.7,8,58.8,11.5,69.2 c0,0.1,0.1,0.1,0.1,0.2c2.8,9.9,10,17.7,19.1,21.2c0.2,0.1,0.5,0.1,0.7,0.2c0.1,0,0.2,0.1,0.3,0.1c1.2,0.4,2.5,0.8,3.7,1.1 c1,0.2,2,0.4,3,0.6c1.1,0.2,2.1,0.3,3.2,0.4c1.2,0.1,2.4,0.2,3.6,0.2c0.1,0,0.2,0,0.3,0h0.1c0.1,0,0.2,0,0.3,0 c1.2,0,2.4-0.1,3.6-0.2c-1.1-0.1-2.1-0.2-3.2-0.4c1-0.2,2-0.4,3-0.6c1.3-0.3,2.5-0.6,3.7-1.1c0.1,0,0.2-0.1,0.3-0.1 c0.2-0.1,0.5-0.1,0.7-0.2c9.1-3.5,16.3-11.3,19.1-21.2c0-0.1,0.1-0.1,0.1-0.2C89,58.8,89,47.7,85.4,37.3z M50,77.9 c-15.4,0-27.9-12.5-27.9-27.9S34.6,22.1,50,22.1s27.9,12.5,27.9,27.9S65.4,77.9,50,77.9z"></path>
<path fill="var(--warm-orange)" d="M50,88.8c-21.4,0-38.8-17.4-38.8-38.8S28.6,11.2,50,11.2s38.8,17.4,38.8,38.8S71.4,88.8,50,88.8z M50,16.2 c-18.7,0-33.8,15.1-33.8,33.8S31.3,83.8,50,83.8s33.8-15.1,33.8-33.8S68.7,16.2,50,16.2z"></path>
</svg>
<span class="text-xl font-bold text-tech-blue">开心APP</span>
</router-link>
<p class="mt-4 text-text-medium">陪伴理解记录共同成长</p>
</div>
<div>
<h3 class="font-semibold text-text-dark">产品</h3>
<ul class="mt-4 space-y-2">
<li><a href="#features-grid" class="text-text-medium hover:text-tech-blue">功能</a></li>
<li><router-link to="/settings" class="text-text-medium hover:text-tech-blue">定价</router-link></li>
<li><router-link to="/messages" class="text-text-medium hover:text-tech-blue">更新日志</router-link></li>
</ul>
</div>
<div>
<h3 class="font-semibold text-text-dark">公司</h3>
<ul class="mt-4 space-y-2">
<li><router-link to="/personal-dashboard" class="text-text-medium hover:text-tech-blue">关于我们</router-link></li>
<li><router-link to="/messages" class="text-text-medium hover:text-tech-blue">联系我们</router-link></li>
<li><router-link to="/settings" class="text-text-medium hover:text-tech-blue">加入我们</router-link></li>
</ul>
</div>
<div>
<h3 class="font-semibold text-text-dark">法律</h3>
<ul class="mt-4 space-y-2">
<li><router-link to="/settings" class="text-text-medium hover:text-tech-blue">隐私政策</router-link></li>
<li><router-link to="/settings" class="text-text-medium hover:text-tech-blue">服务条款</router-link></li>
</ul>
</div>
</div>
<div class="mt-12 border-t border-gray-200 pt-8 text-center text-text-medium">
<p>© 2025 开心APP. All Rights Reserved. 来自"开心"星球的温柔科技</p>
</div>
</div>
</footer>
<!-- 登录弹框已移除统一使用登录页面 -->
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { MessageOutlined, BookOutlined, UserOutlined, LineChartOutlined } from '@ant-design/icons-vue'
import AppHeader from '@/components/layout/AppHeader.vue'
import AppFooter from '@/components/layout/AppFooter.vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import UserDropdown from '@/components/UserDropdown.vue'
import UserAvatar from '@/components/UserAvatar.vue'
// 功能特性数据
const features = [
{
icon: MessageOutlined,
title: '智能对话',
description: '从日常闲聊到情感咨询,开开随时倾听,理解并回应你的每个想法,是永不离线的好朋友。',
image: 'https://r2.flowith.net/files/o/1752574375721-happy_kaikai_character_design_index_0@1024x1024.png',
alt: '开心的开开'
},
{
icon: BookOutlined,
title: '情绪日记',
description: '记录你的点滴心情与生活,开开会给予温暖的回应。在安全的空间里,回顾与成长。',
image: 'https://r2.flowith.net/files/o/1752574488398-kaikai_supportive_comfort_character_index_3@1024x1024.png',
alt: '倾听中的开开'
},
{
icon: UserOutlined,
title: '个人展板',
description: '自由定义你的个性标签,开开还会自动收录你的"精彩语录",构建独一无二的数字人格。',
image: 'https://r2.flowith.net/files/o/1752574426392-kaikai_character_working_digital_workspace_index_4@1024x1024.png',
alt: '工作中的开开'
},
{
icon: LineChartOutlined,
title: '话题追踪',
description: '自动总结你关心的事,无论是生活琐事还是工作计划,都用时间线清晰整理,助你洞察自我。',
image: 'https://r2.flowith.net/files/o/1752574572161-kaikai_character_energetic_animation_index_2@1024x1024.png',
alt: '充满活力的开开'
}
]
const router = useRouter()
const authStore = useAuthStore()
// 滚动动画观察器
let scrollObserver: IntersectionObserver | null = null
// 响应式数据
const isScrolled = ref(false)
const mobileMenuOpen = ref(false)
onMounted(() => {
// 初始化滚动动画
initScrollAnimations()
})
onUnmounted(() => {
if (scrollObserver) {
scrollObserver.disconnect()
}
})
const initScrollAnimations = () => {
scrollObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible')
scrollObserver?.unobserve(entry.target)
}
})
},
{ threshold: 0.1 }
)
// 观察所有需要动画的元素
document.querySelectorAll('.scroll-target').forEach((target) => {
scrollObserver?.observe(target)
})
// 功能特性数据
const features = ref([
{
icon: 'message-circle',
title: '智能对话',
description: '从日常闲聊到情感咨询,开开随时倾听,理解并回应你的每个想法,是永不离线的好朋友。',
image: 'https://r2.flowith.net/files/o/1752574375721-happy_kaikai_character_design_index_0@1024x1024.png',
alt: '开心的开开'
},
{
icon: 'book-open-text',
title: '情绪日记',
description: '记录你的点滴心情与生活,开开会给予温暖的回应。在安全的空间里,回顾与成长。',
image: 'https://r2.flowith.net/files/o/1752574488398-kaikai_supportive_comfort_character_index_3@1024x1024.png',
alt: '倾听中的开开'
},
{
icon: 'user-round-cog',
title: '个人展板',
description: '自由定义你的个性标签,开开还会自动收录你的"精彩语录",构建独一无二的数字人格。',
image: 'https://r2.flowith.net/files/o/1752574426392-kaikai_character_working_digital_workspace_index_4@1024x1024.png',
alt: '工作中的开开'
},
{
icon: 'trending-up',
title: '话题追踪',
description: '自动总结你关心的事,无论是生活琐事还是工作计划,都用时间线清晰整理,助你洞察自我。',
image: 'https://r2.flowith.net/files/o/1752574572161-kaikai_character_energetic_animation_index_2@1024x1024.png',
alt: '充满活力的开开'
}
])
// 滚动监听
const handleScroll = () => {
isScrolled.value = window.scrollY > 10
}
// 移动端菜单切换
const toggleMobileMenu = () => {
mobileMenuOpen.value = !mobileMenuOpen.value
}
/**
* 处理移动端登出
*/
const handleMobileLogout = async () => {
try {
await authStore.logout()
toggleMobileMenu()
ElMessage.success('已退出登录')
} catch (error) {
console.error('登出失败:', error)
ElMessage.error('登出失败')
}
}
// 移除登录弹框相关方法
// 生命周期
onMounted(async () => {
window.addEventListener('scroll', handleScroll)
// 静默恢复本地认证状态(不进行API调用)
if (!authStore.isLoggedIn) {
console.log('🏠 首页静默恢复认证状态')
authStore.restoreLocalAuth()
}
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
// 设置滚动观察器
const scrollObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible')
observer.unobserve(entry.target)
}
})
}, { threshold: 0.1 })
// 观察所有滚动目标
document.querySelectorAll('.scroll-target').forEach(target => {
scrollObserver.observe(target)
})
// 延迟初始化图标,确保DOM已渲染
setTimeout(() => {
if (window.lucide) {
window.lucide.createIcons()
}
}, 100)
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.home-page {
min-height: 100vh;
background: #f5f5f5;
<style scoped>
/* 导入原始样式变量 */
:root {
--tech-blue: #4A90E2;
--warm-orange: #F5A623;
--white: #FFFFFF;
--light-gray: #F7F8FA;
--text-dark: #333333;
--text-medium: #888888;
}
/* Hero Section */
.hero-section {
position: relative;
padding: 128px 24px 80px;
background: white;
text-align: center;
overflow: hidden;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
/* 应用原始样式类 */
.bg-tech-blue { background-color: var(--tech-blue); }
.bg-warm-orange { background-color: var(--warm-orange); }
.bg-light-gray { background-color: var(--light-gray); }
.text-tech-blue { color: var(--tech-blue); }
.text-text-dark { color: var(--text-dark); }
.text-text-medium { color: var(--text-medium); }
.border-tech-blue { border-color: var(--tech-blue); }
@media (max-width: 768px) {
padding: 100px 16px 60px;
min-height: 80vh;
}
#main-header.scrolled {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
border-bottom-color: #e5e7eb;
}
.wave-background {
position: absolute;
inset: 0;
opacity: 0.2;
z-index: 0;
}
.wave {
background: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiB2aWV3Qm94PSIwIDAgMTQ0MCAxNDciIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPmdyb3VwPC90aXRsZT48ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBpZD0iQ29tcG9uZW50LS0tV2F2ZS1Cb3R0b20iIGZpbGw9IiM0QTkwRTIiPjxwYXRoIGQ9Ik0wLDc0LjgzMjk0MTIgQzM2MCw3NC44MzI5NDEyIDM2MCwxNDcgNzIwLDE0NyBDMTA4MCwxNDcgMTA4MCw3NC44MzI5NDEyIDE0NDAsNzQuODMyOTQxMiBMMTQ0MCwxNDcgTDAsMTQ3IEwwLDc0LjgzMjk0MTIgWiIgaWQ9IldhdmUiIG9wYWNpdHk9IjAuMSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+");
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: 147px;
animation: wave 15s linear infinite;
&:nth-child(2) {
animation-direction: reverse;
animation-duration: 20s;
opacity: 0.8;
}
&:nth-child(3) {
animation-duration: 25s;
opacity: 0.5;
}
}
@keyframes wave {
0% { transform: translateX(0); }
50% { transform: translateX(-50%); }
100% { transform: translateX(0); }
}
.hero-content {
position: relative;
z-index: 10;
max-width: 768px;
margin: 0 auto;
}
.hero-title {
font-size: 4rem;
font-weight: 700;
color: $text-dark;
line-height: 1.2;
margin-bottom: 16px;
@media (max-width: 768px) {
font-size: 2.5rem;
}
.highlight {
color: $tech-blue;
}
}
.hero-subtitle {
font-size: 1.5rem;
color: $text-medium;
margin-bottom: 48px;
@media (max-width: 768px) {
font-size: 1.25rem;
margin-bottom: 32px;
}
}
.hero-image {
margin-bottom: 32px;
.kaikai-image {
width: 100%;
max-width: 400px;
height: auto;
border-radius: 20px;
filter: drop-shadow(0 20px 40px rgba(0, 0, 0, 0.1));
@media (max-width: 768px) {
max-width: 300px;
}
}
}
.start-chat-btn {
background: $warm-orange !important;
border: none !important;
border-radius: 50px !important;
padding: 16px 32px !important;
font-size: 18px !important;
font-weight: 600 !important;
height: auto !important;
box-shadow: 0 8px 24px rgba(245, 166, 35, 0.3) !important;
transition: all 0.3s ease !important;
&:hover {
background: #e6951f !important;
transform: translateY(-2px) !important;
box-shadow: 0 12px 32px rgba(245, 166, 35, 0.4) !important;
}
@media (max-width: 768px) {
padding: 12px 24px !important;
font-size: 16px !important;
}
}
/* Features Section */
.features-section {
padding: 80px 24px;
background: $light-gray;
@media (max-width: 768px) {
padding: 60px 16px;
}
}
.features-header {
text-align: center;
max-width: 768px;
margin: 0 auto 64px;
@media (max-width: 768px) {
margin-bottom: 48px;
}
}
.features-title {
font-size: 2.5rem;
font-weight: 700;
color: $text-dark;
margin-bottom: 16px;
@media (max-width: 768px) {
font-size: 2rem;
}
}
.features-subtitle {
font-size: 1.125rem;
color: $text-medium;
line-height: 1.6;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 32px;
max-width: 1024px;
margin: 0 auto;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 24px;
}
}
.feature-card {
background: white;
border-radius: 16px;
padding: 32px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #e5e7eb;
transition: all 0.3s ease;
opacity: 0;
transform: translateY(30px);
&:hover {
transform: translateY(-8px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.12);
}
&.visible {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 768px) {
padding: 24px;
}
}
.feature-image-container {
width: 100%;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
margin-bottom: 24px;
background: linear-gradient(135deg, #eef5fe 0%, #f0f8ff 100%);
background-image: url('data:image/svg+xml;utf8,<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M-10 10 C 20 20, 40 0, 60 10 S 100 0, 120 10" stroke="%234A90E2" fill="none" stroke-width="2" stroke-opacity="0.2"/></svg>');
background-size: 50px;
background-repeat: repeat;
display: flex;
align-items: center;
justify-content: center;
}
.feature-image {
width: 80%;
height: 80%;
object-fit: contain;
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.1));
}
.feature-content {
flex: 1;
}
.feature-header {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 12px;
}
.feature-icon {
width: 20px;
height: 20px;
color: $tech-blue;
}
.feature-title {
font-size: 1.25rem;
font-weight: 600;
color: $text-dark;
margin: 0;
}
.feature-description {
color: $text-medium;
line-height: 1.6;
margin: 0;
}
/* 动画效果 */
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out forwards;
opacity: 0;
}
.delay-300 {
animation-delay: 0.3s;
}
.delay-500 {
animation-delay: 0.5s;
}
.delay-700 {
animation-delay: 0.7s;
}
@keyframes fade-in-up {
from {
opacity: 0;
@@ -442,10 +425,76 @@
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
&.visible {
.scroll-target.visible {
opacity: 1;
transform: translateY(0);
}
.wave {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiB2aWV3Qm94PSIwIDAgMTQ0MCAxNDciIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPmdyb3VwPC90aXRsZT48ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBpZD0iQ29tcG9uZW50LS0tV2F2ZS1Cb3R0b20iIGZpbGw9IiM0QTkwRTIiPjxwYXRoIGQ9Ik0wLDc0LjgzMjk0MTIgQzM2MCw3NC44MzI5NDEyIDM2MCwxNDcgNzIwLDE0NyBDMTA4MCwxNDcgMTA4MCw3NC44MzI5NDEyIDE0NDAsNzQuODMyOTQxMiBMMTQ0MCwxNDcgTDAsMTQ3IEwwLDc0LjgzMjk0MTIgWiIgaWQ9IldhdmUiIG9wYWNpdHk9IjAuMSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+);
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: 147px;
animation: wave 15s linear infinite;
}
.wave:nth-of-type(2) {
animation-direction: reverse;
animation-duration: 20s;
opacity: 0.8;
}
.wave:nth-of-type(3) {
animation-duration: 25s;
opacity: 0.5;
}
@keyframes wave {
0% { transform: translateX(0); }
50% { transform: translateX(-50%); }
100% { transform: translateX(0); }
}
#login-modal:not(.hidden) {
animation: modal-fade-in 0.2s ease-out forwards;
}
#login-modal:not(.hidden) > div {
animation: modal-scale-up 0.2s ease-out forwards;
}
@keyframes modal-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@keyframes modal-scale-up {
from {
transform: scale(0.95);
}
to {
transform: scale(1);
}
}
/* 移除了验证码相关样式 */
/* 全局样式 */
body {
font-family: 'Noto Sans SC', sans-serif;
}
.home-page {
font-family: 'Noto Sans SC', sans-serif;
background-color: var(--light-gray);
color: var(--text-dark);
}
</style>
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="bg-light-gray font-sans text-text-dark flex flex-col h-screen antialiased">
<!-- Header -->
<header class="bg-white shadow-md z-10 flex-shrink-0">
<div class="container mx-auto px-4 py-3 flex items-center justify-between relative">
<div class="flex items-center space-x-4">
<router-link to="/" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="home" class="w-6 h-6"></i>
</router-link>
<router-link to="/messages" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="bell" class="w-6 h-6"></i>
</router-link>
</div>
<h1 class="text-lg font-bold text-text-dark absolute left-1/2 -translate-x-1/2">人生轨迹</h1>
<router-link to="/settings" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="user" class="w-6 h-6"></i>
</router-link>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 overflow-y-auto p-4 lg:p-6 flex items-center justify-center pb-24">
<div class="text-center">
<i data-lucide="milestone" class="w-16 h-16 mx-auto text-gray-300"></i>
<h2 class="mt-4 text-xl font-semibold text-text-dark">记录你的人生轨迹</h2>
<p class="mt-2 text-text-medium">重要的时刻达成的目标难忘的经历...都在这里汇集</p>
<p class="mt-1 text-text-medium">此功能正在建设中敬请期待</p>
</div>
</main>
<!-- App Navigation -->
<BottomNavigation />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import BottomNavigation from '@/components/layout/BottomNavigation.vue'
// 生命周期
onMounted(() => {
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
})
</script>
<style scoped>
/* 导入原始样式变量 */
:root {
--tech-blue: #4A90E2;
--warm-orange: #F5A623;
--white: #FFFFFF;
--light-gray: #F7F8FA;
--text-dark: #333333;
--text-medium: #888888;
}
/* 应用原始样式类 */
.bg-tech-blue { background-color: var(--tech-blue); }
.bg-warm-orange { background-color: var(--warm-orange); }
.bg-light-gray { background-color: var(--light-gray); }
.text-tech-blue { color: var(--tech-blue); }
.text-text-dark { color: var(--text-dark); }
.text-text-medium { color: var(--text-medium); }
.border-tech-blue { border-color: var(--tech-blue); }
.life-milestones-page {
font-family: 'Noto Sans SC', sans-serif;
background-color: var(--light-gray);
color: var(--text-dark);
display: flex;
flex-direction: column;
height: 100vh;
}
/* 全局样式 */
body {
font-family: 'Noto Sans SC', sans-serif;
}
</style>
File diff suppressed because it is too large Load Diff
+378 -270
View File
@@ -1,328 +1,436 @@
<template>
<div class="login-page">
<div class="login-container">
<div class="login-card">
<!-- Logo和标题 -->
<div class="login-header">
<router-link to="/" class="logo">
<span class="logo-text">开心APP</span>
</router-link>
<h1 class="login-title">欢迎回来</h1>
<p class="login-subtitle">登录您的账户继续与开开的对话</p>
<div class="container mx-auto px-4 py-8">
<div class="max-w-md mx-auto card">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">登录</h1>
<p class="text-gray-600">欢迎回到情绪博物馆</p>
</div>
<!-- 登录表单 -->
<a-form
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
@finish="handleLogin"
@finishFailed="handleLoginFailed"
layout="vertical"
class="login-form"
label-width="80px"
@submit.prevent="handleLogin"
>
<a-form-item label="账号" name="account">
<a-input
v-model:value="loginForm.account"
placeholder="请输入手机号或邮箱"
size="large"
:prefix="h(UserOutlined)"
<el-form-item label="账号" prop="account">
<el-input
v-model="loginForm.account"
placeholder="请输入账号/邮箱/手机号"
:prefix-icon="User"
clearable
@keyup.enter="handleLogin"
/>
</a-form-item>
</el-form-item>
<a-form-item label="密码" name="password">
<a-input-password
v-model:value="loginForm.password"
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
size="large"
:prefix="h(LockOutlined)"
:prefix-icon="Lock"
show-password
clearable
@keyup.enter="handleLogin"
/>
</a-form-item>
</el-form-item>
<!-- 验证码 -->
<a-form-item label="验证码" name="captcha">
<div class="captcha-container">
<a-input
v-model:value="loginForm.captcha"
<el-form-item label="验证码" prop="captcha">
<div class="flex gap-2">
<el-input
v-model="loginForm.captcha"
placeholder="请输入验证码"
size="large"
style="flex: 1"
:prefix-icon="Key"
clearable
class="flex-1"
@keyup.enter="handleLogin"
/>
<div class="captcha-image" @click="refreshCaptcha">
<div class="captcha-container" @click="refreshCaptcha">
<img
v-if="captchaImage"
:src="captchaImage"
alt="验证码"
style="width: 100%; height: 100%; cursor: pointer;"
class="captcha-image"
/>
<div v-else class="captcha-loading">
<a-spin size="small" />
<el-icon class="is-loading"><Loading /></el-icon>
</div>
</div>
</div>
<div class="captcha-tip">点击图片刷新验证码</div>
</a-form-item>
</el-form-item>
<a-form-item>
<div class="login-options">
<a-checkbox v-model:checked="loginForm.remember">记住我</a-checkbox>
<a href="#" class="forgot-password">忘记密码</a>
<el-form-item>
<div class="flex justify-between items-center mb-4">
<el-checkbox v-model="loginForm.rememberMe">
记住我
</el-checkbox>
<router-link to="/forgot-password" class="text-primary-600 hover:underline text-sm">
忘记密码
</router-link>
</div>
</a-form-item>
</el-form-item>
<a-form-item>
<a-button
<el-form-item>
<el-button
type="primary"
html-type="submit"
size="large"
:loading="loginLoading"
class="login-button"
block
class="w-full"
:loading="loading"
@click="handleLogin"
>
登录
</a-button>
</a-form-item>
</a-form>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
<!-- 注册链接 -->
<div class="register-link">
还没有账户
<router-link to="/register" class="register-btn">立即注册</router-link>
</el-form>
<div class="divider">
<span></span>
</div>
<div class="social-login">
<el-button class="social-btn wechat" @click="handleSocialLogin('wechat')">
<el-icon><ChatDotRound /></el-icon>
微信登录
</el-button>
<el-button class="social-btn qq" @click="handleSocialLogin('qq')">
<el-icon><User /></el-icon>
QQ登录
</el-button>
</div>
<div class="text-center mt-6">
<router-link to="/register" class="text-primary-600 hover:underline">
还没有账号立即注册
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, h } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
import { authService } from '@/services/auth'
import { useUserStore } from '@/stores/user'
import type { LoginRequest } from '@/types/auth'
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import {
User,
Lock,
Key,
Loading,
ChatDotRound
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import AuthService from '@/services/auth'
import { envConfig } from '@/config/env'
import type { LoginRequest } from '@/types/auth'
import type { FormInstance, FormRules } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// 表单数据
const loginForm = reactive<LoginRequest>({
account: '',
password: '',
captcha: '',
remember: false
})
// 表单引用
const loginFormRef = ref<FormInstance>()
// 表单验证规则
const loginRules = {
account: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 3, message: '账号长度不能少于3位', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 4, message: '验证码长度为4位', trigger: 'blur' }
]
// 加载状态
const loading = ref(false)
// 验证码相关
const captchaImage = ref('')
const captchaKey = ref('')
// 登录表单数据
const loginForm = reactive<LoginRequest>({
account: '',
password: '',
captcha: '',
captchaKey: '',
rememberMe: false
})
// 表单验证规则
const loginRules: FormRules = {
account: [
{ required: true, message: '请输入账号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度必须在6-20位之间', trigger: 'blur' }
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ min: 4, max: 6, message: '验证码长度不正确', trigger: 'blur' }
]
}
/**
* 获取验证码
*/
const getCaptcha = async () => {
try {
const response = await AuthService.getCaptcha()
// 后端返回的数据已经包含了 data:image/png;base64, 前缀,直接使用
captchaImage.value = response.captchaImage
captchaKey.value = response.captchaKey
loginForm.captchaKey = response.captchaKey
} catch (error) {
console.error('获取验证码失败:', error)
ElMessage.error('获取验证码失败')
}
}
// 状态
const loginLoading = ref(false)
const captchaImage = ref('')
const captchaKey = ref('')
/**
* 刷新验证码
*/
const refreshCaptcha = () => {
loginForm.captcha = ''
getCaptcha()
}
// 获取验证码
const getCaptcha = async () => {
try {
const response = await authService.getCaptcha()
console.log('验证码响应:', response)
captchaImage.value = response.captchaImage // 修正字段
captchaKey.value = response.captchaKey // 修正字段
console.log('验证码图片URL:', captchaImage.value.substring(0, 50) + '...')
} catch (error) {
console.error('获取验证码失败:', error)
message.error('获取验证码失败')
}
}
/**
* 处理登录
*/
const handleLogin = async () => {
if (!loginFormRef.value) return
// 刷新验证码
const refreshCaptcha = () => {
getCaptcha()
}
try {
console.log('开始登录流程...')
console.log('登录表单数据:', loginForm)
// 登录处理
const handleLogin = async (values: LoginRequest) => {
loginLoading.value = true
try {
const loginData = {
...values,
captchaKey: captchaKey.value
}
// const data = await userStore.loginWithAuth(loginData)
await userStore.loginWithAuth(loginData)
message.success('登录成功')
await nextTick()
const redirect = router.currentRoute.value.query.redirect as string
const targetPath = redirect || '/'
setTimeout(() => {
try {
router.replace(targetPath).then(() => {
console.log('路由跳转完成')
}).catch((_error) => {
window.location.href = targetPath
})
} catch (error) {
window.location.href = targetPath
}
}, 100)
} catch (error: any) {
message.error(error.message || '登录失败,请稍后重试')
// 表单验证
await loginFormRef.value.validate()
console.log('表单验证通过')
loading.value = true
// 调用登录接口
console.log('调用登录接口...')
const success = await authStore.login(loginForm)
console.log('登录结果:', success)
if (success) {
// 登录成功,确保认证状态已正确设置
console.log('登录成功,当前认证状态:', {
isLoggedIn: authStore.isLoggedIn,
hasToken: !!authStore.accessToken,
hasUserInfo: !!authStore.userInfo
})
// 跳转到目标页面或首页
const redirect = route.query.redirect as string || '/'
console.log('登录成功,跳转到:', redirect)
// 使用路由跳转而不是window.location.href,避免base路径问题
await router.push(redirect)
} else {
// 登录失败,刷新验证码
console.log('登录失败,刷新验证码')
refreshCaptcha()
} finally {
loginLoading.value = false
}
} catch (error) {
console.error('登录过程中发生错误:', error)
ElMessage.error('登录失败,请检查网络连接或稍后重试')
// 刷新验证码
refreshCaptcha()
} finally {
loading.value = false
}
}
// 登录失败处理
const handleLoginFailed = (errorInfo: any) => {
console.log('Login failed:', errorInfo)
/**
* 处理第三方登录
*/
const handleSocialLogin = (platform: 'wechat' | 'qq') => {
ElMessage.info(`${platform === 'wechat' ? '微信' : 'QQ'}登录功能开发中...`)
// TODO: 实现第三方登录逻辑
}
// 组件挂载时获取验证码
onMounted(() => {
getCaptcha()
// 如果已经登录,直接跳转
if (authStore.isLoggedIn) {
const redirect = route.query.redirect as string || '/'
router.push(redirect)
}
// 初始化
onMounted(() => {
getCaptcha()
})
})
</script>
<style scoped>
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-attachment: fixed;
display: flex;
align-items: center;
justify-content: center;
}
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 2rem;
width: 100%;
max-width: 450px;
}
.login-container {
width: 100%;
max-width: 400px;
}
.card {
background: transparent;
border: none;
box-shadow: none;
}
.login-card {
background: white;
.captcha-container {
width: 120px;
height: 40px;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
transition: border-color 0.3s;
}
.captcha-container:hover {
border-color: #409eff;
}
.captcha-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 3px;
}
.captcha-loading {
color: #909399;
font-size: 14px;
}
.divider {
position: relative;
text-align: center;
margin: 1.5rem 0;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #e4e7ed;
}
.divider span {
background: rgba(255, 255, 255, 0.95);
padding: 0 1rem;
color: #909399;
font-size: 14px;
}
.social-login {
display: flex;
gap: 0.5rem;
}
.social-btn {
flex: 1;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s;
}
.social-btn.wechat {
background: #07c160;
border-color: #07c160;
color: white;
}
.social-btn.wechat:hover {
background: #06ad56;
border-color: #06ad56;
transform: translateY(-1px);
}
.social-btn.qq {
background: #12b7f5;
border-color: #12b7f5;
color: white;
}
.social-btn.qq:hover {
background: #0ea5e9;
border-color: #0ea5e9;
transform: translateY(-1px);
}
:deep(.el-form-item__label) {
font-weight: 500;
color: #303133;
}
:deep(.el-input__wrapper) {
border-radius: 8px;
box-shadow: 0 0 0 1px #dcdfe6 inset;
transition: all 0.3s;
}
:deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px #c0c4cc inset;
}
:deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #409eff inset;
}
:deep(.el-button--primary) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
font-weight: 500;
padding: 12px 20px;
transition: all 0.3s;
}
:deep(.el-button--primary:hover) {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.text-primary-600 {
color: #667eea;
text-decoration: none;
font-weight: 500;
transition: color 0.3s;
}
.text-primary-600:hover {
color: #5a6fd8;
text-decoration: underline;
}
/* 响应式设计 */
@media (max-width: 640px) {
.container {
margin: 1rem;
padding: 1.5rem;
border-radius: 16px;
padding: 40px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 32px;
.logo {
display: inline-block;
text-decoration: none;
color: #4A90E2;
font-size: 24px;
font-weight: bold;
margin-bottom: 16px;
}
.login-title {
font-size: 28px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.login-subtitle {
color: #888;
font-size: 14px;
margin: 0;
}
.captcha-container {
width: 100px;
height: 36px;
}
.login-form {
.captcha-container {
display: flex;
gap: 12px;
align-items: center;
}
.captcha-image {
width: 100px;
height: 40px;
border: 1px solid #d9d9d9;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
cursor: pointer;
transition: border-color 0.3s;
&:hover {
border-color: #4A90E2;
}
}
.captcha-loading {
display: flex;
align-items: center;
justify-content: center;
}
.captcha-tip {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.login-options {
display: flex;
justify-content: space-between;
align-items: center;
.forgot-password {
color: #4A90E2;
text-decoration: none;
font-size: 14px;
&:hover {
text-decoration: underline;
}
}
}
.login-button {
background: linear-gradient(135deg, #4A90E2 0%, #5BA0F2 100%);
border: none;
border-radius: 8px;
font-weight: 600;
height: 48px;
font-size: 16px;
}
.social-login {
flex-direction: column;
}
.register-link {
text-align: center;
margin-top: 24px;
color: #888;
font-size: 14px;
.register-btn {
color: #4A90E2;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
}
</style>
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<template>
<div class="map-page">
<div class="container mx-auto px-4 py-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">情绪地图</h1>
<p class="text-gray-600">在地图上标记情绪地点记录美好回忆</p>
</div>
<div class="card">
<div class="text-center text-gray-500 py-12">
<el-icon class="text-4xl mb-4">
<Location />
</el-icon>
<p>情绪地图功能开发中...</p>
<p class="text-sm mt-2">这里将集成高德地图API支持情绪地点标记</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Location } from '@element-plus/icons-vue'
</script>
<style scoped>
.map-page {
min-height: 100vh;
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
background-attachment: fixed;
}
.map-page .container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
margin-top: 2rem;
margin-bottom: 2rem;
}
</style>
+130 -569
View File
@@ -1,590 +1,151 @@
<template>
<div class="messages-page">
<!-- 头部 -->
<header class="page-header">
<div class="header-content">
<div class="header-left">
<a-button type="text" @click="$router.back()" class="back-btn">
<ArrowLeftOutlined />
</a-button>
<h1 class="page-title">消息中心</h1>
<div class="bg-light-gray font-sans text-text-dark">
<div id="app-container" class="antialiased">
<!-- App Header -->
<header class="fixed top-0 left-0 right-0 z-40 bg-white/90 backdrop-blur-md border-b border-gray-200/80">
<div class="container mx-auto px-4 h-16 flex items-center justify-between relative">
<button @click="goBack" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="chevron-left" class="w-6 h-6"></i>
</button>
<h1 class="text-lg font-semibold text-text-dark absolute left-1/2 -translate-x-1/2">消息中心</h1>
<router-link to="/" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="home" class="w-6 h-6"></i>
</router-link>
</div>
<div class="header-actions">
<a-button type="text" @click="markAllAsRead" :disabled="unreadCount === 0">
全部已读
</a-button>
<a-button type="text" @click="clearAllMessages" danger>
<DeleteOutlined />
清空
</a-button>
</div>
</div>
</header>
</header>
<!-- 主要内容 -->
<main class="page-main">
<div class="container">
<!-- 筛选标签 -->
<div class="filter-tabs">
<a-radio-group v-model:value="activeTab" @change="handleTabChange">
<a-radio-button value="all">
全部 <a-badge :count="messages.length" :show-zero="false" />
</a-radio-button>
<a-radio-button value="unread">
未读 <a-badge :count="unreadCount" :show-zero="false" />
</a-radio-button>
<a-radio-button value="system">
系统消息 <a-badge :count="systemCount" :show-zero="false" />
</a-radio-button>
<a-radio-button value="notification">
通知 <a-badge :count="notificationCount" :show-zero="false" />
</a-radio-button>
<a-radio-button value="reminder">
提醒 <a-badge :count="reminderCount" :show-zero="false" />
</a-radio-button>
</a-radio-group>
</div>
<!-- 消息列表 -->
<div class="messages-list">
<div
v-for="message in filteredMessages"
:key="message.id"
class="message-item"
:class="{ 'unread': message.status === 'unread' }"
@click="handleMessageClick(message)"
>
<div class="message-icon">
<div class="icon-wrapper" :class="`type-${message.type}`">
<component :is="getMessageIcon(message.type)" />
<main class="pt-20 pb-8 bg-light-gray min-h-screen">
<div class="container mx-auto px-6">
<div id="message-list" class="max-w-3xl mx-auto space-y-4">
<div
v-for="(msg, index) in messages"
:key="index"
class="bg-white p-5 rounded-xl shadow-sm border border-gray-200/80 flex items-start space-x-4 hover:shadow-md hover:border-tech-blue/30 transition-all duration-300 animate-fade-in-up"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-light-gray flex items-center justify-center border">
<i :data-lucide="msg.icon" class="w-5 h-5" :class="msg.color"></i>
</div>
</div>
<div class="message-content">
<div class="message-header">
<h3 class="message-title">{{ message.title }}</h3>
<div class="message-meta">
<a-tag :color="getTypeColor(message.type)" size="small">
{{ getTypeText(message.type) }}
</a-tag>
<span class="message-time">{{ formatTime.friendly(message.createTime) }}</span>
<div class="flex-grow">
<div class="flex justify-between items-center">
<h3 class="font-bold text-text-dark">{{ msg.title }}</h3>
<span class="text-xs text-text-medium whitespace-nowrap">{{ msg.timestamp }}</span>
</div>
<p class="text-text-medium mt-1 pr-4">{{ msg.content }}</p>
</div>
<p class="message-text">{{ message.content }}</p>
<div class="message-actions" v-if="message.actionUrl">
<a-button type="link" size="small" @click.stop="handleAction(message)">
查看详情
<RightOutlined />
</a-button>
</div>
</div>
<div class="message-controls">
<a-button
type="text"
size="small"
@click.stop="toggleReadStatus(message)"
:title="message.status === 'read' ? '标记为未读' : '标记为已读'"
>
<EyeOutlined v-if="message.status === 'unread'" />
<EyeInvisibleOutlined v-else />
</a-button>
<a-button
type="text"
size="small"
danger
@click.stop="deleteMessage(message.id)"
title="删除消息"
>
<DeleteOutlined />
</a-button>
<button class="flex-shrink-0 text-text-medium hover:text-tech-blue self-center">
<i data-lucide="chevron-right" class="w-5 h-5"></i>
</button>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredMessages.length === 0" class="empty-state">
<a-empty
:description="getEmptyDescription()"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
/>
</div>
<!-- 加载更多 -->
<div class="load-more" v-if="hasMore">
<a-button @click="loadMore" :loading="isLoading" block>
加载更多
</a-button>
</div>
</div>
</main>
<!-- 消息详情模态框 -->
<a-modal
v-model:open="showDetailModal"
:title="selectedMessage?.title"
:footer="null"
width="600px"
>
<div v-if="selectedMessage" class="message-detail">
<div class="detail-header">
<a-tag :color="getTypeColor(selectedMessage.type)">
{{ getTypeText(selectedMessage.type) }}
</a-tag>
<span class="detail-time">{{ formatTime.standard(selectedMessage.createTime) }}</span>
</div>
<div class="detail-content">
<p>{{ selectedMessage.content }}</p>
</div>
<div class="detail-actions" v-if="selectedMessage.actionUrl">
<a-button type="primary" @click="handleAction(selectedMessage)">
查看详情
</a-button>
</div>
</div>
</a-modal>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
ArrowLeftOutlined,
DeleteOutlined,
RightOutlined,
EyeOutlined,
EyeInvisibleOutlined,
BellOutlined,
InfoCircleOutlined,
ClockCircleOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'
import { Empty, message } from 'ant-design-vue'
import { formatTime } from '@/utils'
import type { Message } from '@/types'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
// 响应式数据
const activeTab = ref('all')
const showDetailModal = ref(false)
const selectedMessage = ref<Message | null>(null)
const isLoading = ref(false)
const hasMore = ref(false)
const router = useRouter()
// 消息数据
const messages = ref<Message[]>([
{
id: '1',
title: '欢迎使用开心APP',
content: '感谢您注册开心APP!我是您的情绪陪伴使者开开,很高兴认识您。让我们一起开始这段美好的情绪陪伴之旅吧!',
type: 'system',
status: 'unread',
createTime: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
actionUrl: '/chat'
},
{
id: '2',
title: '每日心情记录提醒',
content: '今天还没有记录心情哦~花几分钟写下今天的感受,让开开更好地了解您的情绪变化。',
type: 'reminder',
status: 'unread',
createTime: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
actionUrl: '/diary'
},
{
id: '3',
title: '新功能上线通知',
content: '话题追踪功能已上线!现在您可以创建和管理感兴趣的话题,让开开帮您更好地整理思路。',
type: 'notification',
status: 'read',
createTime: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
actionUrl: '/topic-tracker'
},
{
id: '4',
title: '系统维护通知',
content: '系统将于今晚23:00-24:00进行例行维护,期间可能会影响部分功能的使用,请您谅解。',
type: 'system',
status: 'read',
createTime: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: '5',
title: '个人展板完善提醒',
content: '完善您的个人展板信息,让开开更好地了解您的兴趣爱好和生活技能,提供更个性化的陪伴。',
type: 'reminder',
status: 'read',
createTime: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
actionUrl: '/dashboard'
// 消息数据
const messages = ref([
{
type: 'ai',
icon: 'sparkles',
color: 'text-warm-orange',
title: '开开的每周心情总结',
content: '你好呀!上周我们聊了很多关于"新工作的挑战",你表现出了很棒的适应能力和积极心态。记得给自己一些放松的时间哦,比如看看你喜欢的电影。',
timestamp: '2025年7月15日 09:30'
},
{
type: 'system',
icon: 'bell',
color: 'text-tech-blue',
title: '系统通知:欢迎使用日记功能',
content: '现在,你可以在日记区记录下你的生活点滴,开开会阅读你的日记并给你温暖的回复和鼓励哦。',
timestamp: '2025年7月14日 18:00'
},
{
type: 'ai',
icon: 'sparkles',
color: 'text-warm-orange',
title: '开开的话题追踪提醒',
content: '我发现你最近经常提到"学吉他",我已经为你创建了一个话题追踪卡片,帮你记录学习进度和心得。一起加油吧!',
timestamp: '2025年7月12日 11:25'
},
{
type: 'system',
icon: 'award',
color: 'text-green-500',
title: '成就解锁:初次见面',
content: '恭喜你完成了与开开的第一次对话,这是共同成长的第一步。',
timestamp: '2025年7月10日 20:45'
}
])
// 返回上一页
const goBack = () => {
router.back()
}
// 生命周期
onMounted(() => {
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
// 延迟初始化图标
setTimeout(() => {
if (window.lucide) {
window.lucide.createIcons()
}
])
// 计算属性
const filteredMessages = computed(() => {
switch (activeTab.value) {
case 'unread':
return messages.value.filter(msg => msg.status === 'unread')
case 'system':
return messages.value.filter(msg => msg.type === 'system')
case 'notification':
return messages.value.filter(msg => msg.type === 'notification')
case 'reminder':
return messages.value.filter(msg => msg.type === 'reminder')
default:
return messages.value
}
})
const unreadCount = computed(() =>
messages.value.filter(msg => msg.status === 'unread').length
)
const systemCount = computed(() =>
messages.value.filter(msg => msg.type === 'system').length
)
const notificationCount = computed(() =>
messages.value.filter(msg => msg.type === 'notification').length
)
const reminderCount = computed(() =>
messages.value.filter(msg => msg.type === 'reminder').length
)
// 方法
const getMessageIcon = (type: Message['type']) => {
const icons = {
system: SettingOutlined,
notification: BellOutlined,
reminder: ClockCircleOutlined
}
return icons[type] || InfoCircleOutlined
}
const getTypeColor = (type: Message['type']) => {
const colors = {
system: 'blue',
notification: 'green',
reminder: 'orange'
}
return colors[type] || 'default'
}
const getTypeText = (type: Message['type']) => {
const texts = {
system: '系统消息',
notification: '通知',
reminder: '提醒'
}
return texts[type] || type
}
const getEmptyDescription = () => {
switch (activeTab.value) {
case 'unread':
return '暂无未读消息'
case 'system':
return '暂无系统消息'
case 'notification':
return '暂无通知消息'
case 'reminder':
return '暂无提醒消息'
default:
return '暂无消息'
}
}
const handleTabChange = () => {
// 标签切换逻辑已在计算属性中处理
}
const handleMessageClick = (msg: Message) => {
// 标记为已读
if (msg.status === 'unread') {
msg.status = 'read'
}
// 显示详情
selectedMessage.value = msg
showDetailModal.value = true
}
const toggleReadStatus = (msg: Message) => {
msg.status = msg.status === 'read' ? 'unread' : 'read'
message.success(`${msg.status === 'read' ? '标记为已读' : '标记为未读'}`)
}
const deleteMessage = (id: string) => {
const index = messages.value.findIndex(msg => msg.id === id)
if (index > -1) {
messages.value.splice(index, 1)
message.success('消息删除成功')
}
}
const markAllAsRead = () => {
messages.value.forEach(msg => {
if (msg.status === 'unread') {
msg.status = 'read'
}
})
message.success('所有消息已标记为已读')
}
const clearAllMessages = () => {
messages.value.length = 0
message.success('所有消息已清空')
}
const handleAction = (msg: Message) => {
if (msg.actionUrl) {
// 这里可以使用路由跳转
message.info(`跳转到:${msg.actionUrl}`)
}
}
const loadMore = () => {
isLoading.value = true
// 模拟加载更多
setTimeout(() => {
isLoading.value = false
hasMore.value = false
}, 1000)
}
// 组件挂载
onMounted(() => {
// 初始化消息数据
})
}, 100)
})
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.messages-page {
min-height: 100vh;
background: $light-gray;
<style scoped>
/* 导入原始样式变量 */
:root {
--tech-blue: #4A90E2;
--warm-orange: #F5A623;
--white: #FFFFFF;
--light-gray: #F7F8FA;
--text-dark: #333333;
--text-medium: #888888;
}
/* 应用原始样式类 */
.bg-tech-blue { background-color: var(--tech-blue); }
.bg-warm-orange { background-color: var(--warm-orange); }
.bg-light-gray { background-color: var(--light-gray); }
.text-tech-blue { color: var(--tech-blue); }
.text-warm-orange { color: var(--warm-orange); }
.text-text-dark { color: var(--text-dark); }
.text-text-medium { color: var(--text-medium); }
.border-tech-blue { border-color: var(--tech-blue); }
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out forwards;
opacity: 0;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
.page-header {
background: white;
box-shadow: $shadow-sm;
position: sticky;
top: 0;
z-index: 10;
to {
opacity: 1;
transform: translateY(0);
}
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
max-width: 1000px;
margin: 0 auto;
}
.header-left {
display: flex;
align-items: center;
gap: $spacing-md;
}
.back-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
.page-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-dark;
margin: 0;
}
.header-actions {
display: flex;
gap: $spacing-sm;
}
.page-main {
padding: $spacing-lg;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.filter-tabs {
margin-bottom: $spacing-xl;
:deep(.ant-radio-group) {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
}
.messages-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.message-item {
background: white;
border-radius: $border-radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-sm;
display: flex;
gap: $spacing-md;
cursor: pointer;
transition: all $transition-normal;
border-left: 4px solid transparent;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-1px);
}
&.unread {
border-left-color: $tech-blue;
background: linear-gradient(90deg, rgba(74, 144, 226, 0.02) 0%, white 100%);
}
.message-icon {
flex-shrink: 0;
.icon-wrapper {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: $font-size-lg;
&.type-system {
background: #1890ff;
}
&.type-notification {
background: #52c41a;
}
&.type-reminder {
background: #fa8c16;
}
}
}
.message-content {
flex: 1;
min-width: 0;
.message-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-sm;
gap: $spacing-md;
.message-title {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-dark;
margin: 0;
line-height: 1.4;
}
.message-meta {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-shrink: 0;
.message-time {
font-size: $font-size-sm;
color: $text-medium;
white-space: nowrap;
}
}
}
.message-text {
color: $text-medium;
line-height: 1.5;
margin: 0 0 $spacing-sm 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.message-actions {
.ant-btn-link {
padding: 0;
height: auto;
font-size: $font-size-sm;
}
}
}
.message-controls {
display: flex;
flex-direction: column;
gap: $spacing-xs;
flex-shrink: 0;
}
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: $spacing-xxl;
}
.load-more {
margin-top: $spacing-xl;
}
// 消息详情模态框
.message-detail {
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 1px solid #f0f0f0;
.detail-time {
color: $text-medium;
font-size: $font-size-sm;
}
}
.detail-content {
margin-bottom: $spacing-lg;
p {
color: $text-dark;
line-height: 1.6;
margin: 0;
}
}
.detail-actions {
padding-top: $spacing-md;
border-top: 1px solid #f0f0f0;
}
}
</style>
/* 全局样式 */
body {
font-family: 'Noto Sans SC', sans-serif;
}
</style>
-100
View File
@@ -1,100 +0,0 @@
<template>
<div class="not-found-page">
<div class="not-found-content">
<div class="error-illustration">
<div class="error-code">404</div>
<div class="error-icon">
<FrownOutlined />
</div>
</div>
<h1 class="error-title">页面未找到</h1>
<p class="error-description">
抱歉您访问的页面不存在或已被移动
</p>
<div class="error-actions">
<a-button type="primary" @click="$router.push('/')" size="large">
<HomeOutlined />
返回首页
</a-button>
<a-button @click="$router.back()" size="large">
<ArrowLeftOutlined />
返回上页
</a-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FrownOutlined, HomeOutlined, ArrowLeftOutlined } from '@ant-design/icons-vue'
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.not-found-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, $light-gray 0%, white 100%);
padding: $spacing-lg;
}
.not-found-content {
text-align: center;
max-width: 500px;
}
.error-illustration {
position: relative;
margin-bottom: $spacing-xxl;
}
.error-code {
font-size: 8rem;
font-weight: $font-weight-bold;
color: $tech-blue;
opacity: 0.1;
line-height: 1;
@media (min-width: $breakpoint-md) {
font-size: 12rem;
}
}
.error-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 4rem;
color: $text-medium;
}
.error-title {
font-size: 2rem;
font-weight: $font-weight-bold;
color: $text-dark;
margin-bottom: $spacing-md;
@media (min-width: $breakpoint-md) {
font-size: 2.5rem;
}
}
.error-description {
font-size: $font-size-lg;
color: $text-medium;
margin-bottom: $spacing-xxl;
line-height: 1.6;
}
.error-actions {
display: flex;
gap: $spacing-md;
justify-content: center;
flex-wrap: wrap;
}
</style>
+27
View File
@@ -0,0 +1,27 @@
<template>
<div class="notfound-page">
<div class="container mx-auto px-4 py-24 text-center">
<h1 class="text-6xl font-bold text-primary-600 mb-4">404</h1>
<p class="text-2xl text-gray-700 mb-6">页面未找到</p>
<router-link to="/" class="btn-primary">返回首页</router-link>
</div>
</div>
</template>
<script setup lang="ts">
// 404页面逻辑
</script>
<style scoped>
.notfound-page {
min-height: 100vh;
background: linear-gradient(135deg, #f857a6 0%, #ff5858 100%);
background-attachment: fixed;
}
.notfound-page .container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<template>
<div class="personal-dashboard-page">
<!-- Header -->
<header class="bg-white shadow-md z-10 flex-shrink-0">
<div class="container mx-auto px-4 py-3 flex items-center justify-between relative">
<div class="flex items-center space-x-4">
<router-link to="/" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="home" class="w-6 h-6"></i>
</router-link>
<router-link to="/messages" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="bell" class="w-6 h-6"></i>
</router-link>
</div>
<h1 class="text-lg font-bold text-text-dark absolute left-1/2 -translate-x-1/2">个人展板</h1>
<router-link to="/settings" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="user" class="w-6 h-6"></i>
</router-link>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 overflow-y-auto p-4 lg:p-6 pb-24">
<div id="dashboard-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Basic Info Card -->
<div class="bg-white p-6 rounded-xl shadow-sm">
<div class="flex justify-between items-center mb-4">
<h2 class="font-bold text-text-dark text-lg">基础信息</h2>
<i data-lucide="user-round" class="text-tech-blue"></i>
</div>
<div id="basic-info-container" class="grid grid-cols-2 gap-4 text-sm">
<div class="flex flex-col">
<span class="text-text-medium">用户名</span>
<span class="font-semibold text-text-dark">开心用户</span>
</div>
<div class="flex flex-col">
<span class="text-text-medium">注册时间</span>
<span class="font-semibold text-text-dark">2024年1月</span>
</div>
<div class="flex flex-col">
<span class="text-text-medium">日记数量</span>
<span class="font-semibold text-text-dark">42</span>
</div>
<div class="flex flex-col">
<span class="text-text-medium">连续记录</span>
<span class="font-semibold text-text-dark">15</span>
</div>
</div>
</div>
<!-- Mood Chart Card -->
<div class="bg-white p-6 rounded-xl shadow-sm">
<div class="flex justify-between items-center mb-4">
<h2 class="font-bold text-text-dark text-lg">近期心情统计</h2>
<i data-lucide="activity" class="text-warm-orange"></i>
</div>
<div class="relative h-48">
<canvas id="moodChart"></canvas>
</div>
</div>
<!-- Interests Card -->
<div class="bg-white p-6 rounded-xl shadow-sm">
<div class="flex justify-between items-center mb-4">
<h2 class="font-bold text-text-dark text-lg">兴趣爱好</h2>
<button
@click="addInterest"
class="text-text-medium hover:text-tech-blue transition-colors"
title="添加兴趣"
>
<i data-lucide="plus" class="w-5 h-5"></i>
</button>
</div>
<div id="interests-container" class="flex flex-wrap gap-2 text-sm min-h-[36px]">
<span class="bg-tech-blue/10 text-tech-blue px-3 py-1 rounded-full">阅读</span>
<span class="bg-tech-blue/10 text-tech-blue px-3 py-1 rounded-full">音乐</span>
<span class="bg-tech-blue/10 text-tech-blue px-3 py-1 rounded-full">旅行</span>
<span class="bg-tech-blue/10 text-tech-blue px-3 py-1 rounded-full">摄影</span>
</div>
<div class="mt-4">
<button
@click="exploreInterests"
class="w-full text-sm bg-tech-blue/10 text-tech-blue font-semibold py-2 px-4 rounded-lg hover:bg-tech-blue/20 transition-colors flex items-center justify-center space-x-2"
>
<i data-lucide="sparkles" class="w-4 h-4"></i>
<span>探索可能发展的爱好</span>
</button>
</div>
</div>
<!-- Skills Card -->
<div class="bg-white p-6 rounded-xl shadow-sm">
<div class="flex justify-between items-center mb-4">
<h2 class="font-bold text-text-dark text-lg">生活技能</h2>
<button
@click="addSkill"
class="text-text-medium hover:text-tech-blue transition-colors"
title="添加技能"
>
<i data-lucide="plus" class="w-5 h-5"></i>
</button>
</div>
<div id="skills-container" class="flex flex-wrap gap-2 text-sm min-h-[36px]">
<span class="bg-warm-orange/10 text-warm-orange px-3 py-1 rounded-full">烹饪</span>
<span class="bg-warm-orange/10 text-warm-orange px-3 py-1 rounded-full">绘画</span>
<span class="bg-warm-orange/10 text-warm-orange px-3 py-1 rounded-full">编程</span>
</div>
<div class="mt-4">
<button
@click="exploreSkills"
class="w-full text-sm bg-tech-blue/10 text-tech-blue font-semibold py-2 px-4 rounded-lg hover:bg-tech-blue/20 transition-colors flex items-center justify-center space-x-2"
>
<i data-lucide="flask-conical" class="w-4 h-4"></i>
<span>探索可能发展的技能</span>
</button>
</div>
</div>
<!-- Personal Quotes Module -->
<div class="lg:col-span-2 bg-white p-6 rounded-xl shadow-sm">
<div class="flex justify-between items-center mb-4">
<h2 class="font-bold text-text-dark text-lg">个人语录</h2>
<button
@click="addQuote"
class="text-text-medium hover:text-tech-blue transition-colors"
title="添加语录"
>
<i data-lucide="plus-square" class="w-5 h-5"></i>
</button>
</div>
<div id="quotes-container" class="space-y-4">
<div class="bg-gray-50 p-4 rounded-lg border-l-4 border-tech-blue">
<p class="text-text-dark italic">"每一天都是新的开始,每一次尝试都是成长的机会。"</p>
<p class="text-text-medium text-xs mt-2">2024年1月15日</p>
</div>
<div class="bg-gray-50 p-4 rounded-lg border-l-4 border-warm-orange">
<p class="text-text-dark italic">"困难是成长路上的垫脚石,而不是绊脚石。"</p>
<p class="text-text-medium text-xs mt-2">2024年1月10日</p>
</div>
</div>
</div>
<!-- Dynamic modules will be added here -->
</div>
<!-- Add custom module button -->
<div class="mt-6 text-center">
<button
@click="addCustomModule"
class="bg-warm-orange text-white px-6 py-3 rounded-full font-semibold hover:bg-orange-600 transition-all duration-300 transform hover:scale-105 shadow-lg shadow-orange-500/30 flex items-center justify-center space-x-2 mx-auto"
>
<i data-lucide="layout-template" class="w-5 h-5"></i>
<span>自由添加模块</span>
</button>
</div>
</main>
<!-- App Navigation -->
<BottomNavigation />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import BottomNavigation from '@/components/layout/BottomNavigation.vue'
// 响应式数据
const interests = ref(['阅读', '音乐', '旅行', '摄影'])
const skills = ref(['烹饪', '绘画', '编程'])
const quotes = ref([
{
text: '"每一天都是新的开始,每一次尝试都是成长的机会。"',
date: '2024年1月15日',
color: 'tech-blue'
},
{
text: '"困难是成长路上的垫脚石,而不是绊脚石。"',
date: '2024年1月10日',
color: 'warm-orange'
}
])
// 添加兴趣
const addInterest = () => {
// TODO: 实现添加兴趣逻辑
console.log('添加兴趣')
}
// 探索兴趣
const exploreInterests = () => {
// TODO: 实现探索兴趣逻辑
console.log('探索兴趣')
}
// 添加技能
const addSkill = () => {
// TODO: 实现添加技能逻辑
console.log('添加技能')
}
// 探索技能
const exploreSkills = () => {
// TODO: 实现探索技能逻辑
console.log('探索技能')
}
// 添加语录
const addQuote = () => {
// TODO: 实现添加语录逻辑
console.log('添加语录')
}
// 添加自定义模块
const addCustomModule = () => {
// TODO: 实现添加自定义模块逻辑
console.log('添加自定义模块')
}
// 生命周期
onMounted(() => {
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
// TODO: 初始化心情图表
// 这里可以集成Chart.js来绘制心情统计图表
})
</script>
<style scoped>
/* 导入原始样式变量 */
:root {
--tech-blue: #4A90E2;
--warm-orange: #F5A623;
--white: #FFFFFF;
--light-gray: #F7F8FA;
--text-dark: #333333;
--text-medium: #888888;
}
/* 应用原始样式类 */
.bg-tech-blue { background-color: var(--tech-blue); }
.bg-warm-orange { background-color: var(--warm-orange); }
.bg-light-gray { background-color: var(--light-gray); }
.text-tech-blue { color: var(--tech-blue); }
.text-text-dark { color: var(--text-dark); }
.text-text-medium { color: var(--text-medium); }
.border-tech-blue { border-color: var(--tech-blue); }
.personal-dashboard-page {
font-family: 'Noto Sans SC', sans-serif;
background-color: var(--light-gray);
color: var(--text-dark);
display: flex;
flex-direction: column;
height: 100vh;
}
/* 全局样式 */
body {
font-family: 'Noto Sans SC', sans-serif;
}
</style>
+23 -547
View File
@@ -1,563 +1,39 @@
<template>
<div class="profile-page">
<!-- 头部 -->
<header class="page-header">
<div class="header-content">
<div class="header-left">
<a-button type="text" @click="$router.back()" class="back-btn">
<ArrowLeftOutlined />
</a-button>
<h1 class="page-title">个人中心</h1>
</div>
<a-button type="text" @click="handleLogout" class="logout-btn">
<LogoutOutlined />
退出登录
</a-button>
<div class="container mx-auto px-4 py-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">个人中心</h1>
<p class="text-gray-600">管理个人信息查看情绪档案</p>
</div>
</header>
<!-- 主要内容 -->
<main class="page-main">
<div class="container">
<!-- 用户信息卡片 -->
<a-card class="user-info-card" :loading="loading">
<div class="user-header">
<div class="avatar-section">
<a-avatar :size="80" :src="userInfo?.avatar" class="user-avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<a-button type="link" size="small" @click="showAvatarModal = true">
更换头像
</a-button>
</div>
<div class="user-details">
<h2 class="username">{{ userInfo?.nickname || userInfo?.username || '未设置昵称' }}</h2>
<p class="user-account">账号{{ userInfo?.account }}</p>
<p class="user-status">
<a-tag color="green">
正常
</a-tag>
</p>
</div>
</div>
</a-card>
<!-- 功能菜单 -->
<div class="menu-section">
<a-card title="账户管理" class="menu-card">
<div class="menu-list">
<div class="menu-item" @click="showEditProfileModal = true">
<EditOutlined class="menu-icon" />
<span class="menu-text">编辑个人信息</span>
<RightOutlined class="menu-arrow" />
</div>
<div class="menu-item" @click="showChangePasswordModal = true">
<LockOutlined class="menu-icon" />
<span class="menu-text">修改密码</span>
<RightOutlined class="menu-arrow" />
</div>
</div>
</a-card>
<a-card title="应用设置" class="menu-card">
<div class="menu-list">
<div class="menu-item" @click="$router.push('/settings')">
<SettingOutlined class="menu-icon" />
<span class="menu-text">系统设置</span>
<RightOutlined class="menu-arrow" />
</div>
<div class="menu-item" @click="showAboutModal = true">
<InfoCircleOutlined class="menu-icon" />
<span class="menu-text">关于应用</span>
<RightOutlined class="menu-arrow" />
</div>
</div>
</a-card>
</div>
<!-- 统计信息 -->
<a-card title="使用统计" class="stats-card">
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">{{ stats.loginCount || 0 }}</div>
<div class="stat-label">登录次数</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.chatCount || 0 }}</div>
<div class="stat-label">聊天次数</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.diaryCount || 0 }}</div>
<div class="stat-label">日记数量</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ formatDate(userInfo?.createTime || '') }}</div>
<div class="stat-label">注册时间</div>
</div>
</div>
</a-card>
</div>
</main>
<!-- 编辑个人信息模态框 -->
<a-modal
v-model:open="showEditProfileModal"
title="编辑个人信息"
@ok="handleUpdateProfile"
:confirm-loading="updateLoading"
>
<a-form :model="profileForm" layout="vertical">
<a-form-item label="昵称">
<a-input v-model:value="profileForm.nickname" placeholder="请输入昵称" />
</a-form-item>
<a-form-item label="邮箱">
<a-input v-model:value="profileForm.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item label="手机号">
<a-input v-model:value="profileForm.phone" placeholder="请输入手机号" />
</a-form-item>
</a-form>
</a-modal>
<!-- 修改密码模态框 -->
<a-modal
v-model:open="showChangePasswordModal"
title="修改密码"
@ok="handleChangePassword"
:confirm-loading="passwordLoading"
>
<a-form :model="passwordForm" layout="vertical">
<a-form-item label="当前密码">
<a-input-password v-model:value="passwordForm.oldPassword" placeholder="请输入当前密码" />
</a-form-item>
<a-form-item label="新密码">
<a-input-password v-model:value="passwordForm.newPassword" placeholder="请输入新密码" />
</a-form-item>
<a-form-item label="确认新密码">
<a-input-password v-model:value="passwordForm.confirmPassword" placeholder="请再次输入新密码" />
</a-form-item>
</a-form>
</a-modal>
<!-- 更换头像模态框 -->
<a-modal
v-model:open="showAvatarModal"
title="更换头像"
@ok="handleUpdateAvatar"
:confirm-loading="avatarLoading"
>
<div class="avatar-upload">
<a-upload
v-model:file-list="avatarFileList"
:before-upload="beforeAvatarUpload"
list-type="picture-card"
:show-upload-list="false"
>
<div v-if="avatarUrl">
<img :src="avatarUrl" alt="avatar" style="width: 100%; height: 100%; object-fit: cover;" />
</div>
<div v-else>
<PlusOutlined />
<div style="margin-top: 8px">上传头像</div>
</div>
</a-upload>
</div>
</a-modal>
<!-- 关于应用模态框 -->
<a-modal
v-model:open="showAboutModal"
title="关于应用"
:footer="null"
>
<div class="about-content">
<div class="app-info">
<h3>情感博物馆</h3>
<p>版本v1.0.0</p>
<p>一个专注于情感记录与分析的智能应用</p>
<div class="card">
<div class="text-center text-gray-500 py-12">
<el-icon class="text-4xl mb-4">
<User />
</el-icon>
<p>个人中心功能开发中...</p>
<p class="text-sm mt-2">这里将展示用户信息情绪统计等内容</p>
</div>
</div>
</a-modal>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
ArrowLeftOutlined,
UserOutlined,
EditOutlined,
LockOutlined,
SettingOutlined,
InfoCircleOutlined,
RightOutlined,
LogoutOutlined,
PlusOutlined
} from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user'
// import { authService } from '@/services/auth'
const router = useRouter()
const userStore = useUserStore()
// 响应式数据
const loading = ref(false)
const updateLoading = ref(false)
const passwordLoading = ref(false)
const avatarLoading = ref(false)
// 模态框显示状态
const showEditProfileModal = ref(false)
const showChangePasswordModal = ref(false)
const showAvatarModal = ref(false)
const showAboutModal = ref(false)
// 表单数据
const profileForm = reactive({
nickname: '',
email: '',
phone: ''
})
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
// 头像相关
const avatarFileList = ref([])
const avatarUrl = ref('')
// 统计数据
const stats = reactive({
loginCount: 0,
chatCount: 0,
diaryCount: 0
})
// 计算属性
const userInfo = computed(() => userStore.userInfo)
// 方法
const formatDate = (dateString: string) => {
if (!dateString) return '未知'
return new Date(dateString).toLocaleDateString()
}
const handleLogout = async () => {
try {
await userStore.logout()
message.success('退出登录成功')
router.push('/login')
} catch (error) {
message.error('退出登录失败')
}
}
const handleUpdateProfile = async () => {
updateLoading.value = true
try {
// TODO: 调用更新个人信息API
message.success('个人信息更新成功')
showEditProfileModal.value = false
} catch (error) {
message.error('更新失败')
} finally {
updateLoading.value = false
}
}
const handleChangePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
message.error('两次输入的密码不一致')
return
}
passwordLoading.value = true
try {
// TODO: 调用修改密码API
message.success('密码修改成功')
showChangePasswordModal.value = false
// 清空表单
Object.assign(passwordForm, {
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
} catch (error) {
message.error('密码修改失败')
} finally {
passwordLoading.value = false
}
}
const beforeAvatarUpload = (file: File) => {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
if (!isJpgOrPng) {
message.error('只能上传 JPG/PNG 格式的图片!')
return false
}
const isLt2M = file.size / 1024 / 1024 < 2
if (!isLt2M) {
message.error('图片大小不能超过 2MB!')
return false
}
// 预览图片
const reader = new FileReader()
reader.onload = (e) => {
avatarUrl.value = e.target?.result as string
}
reader.readAsDataURL(file)
return false // 阻止自动上传
}
const handleUpdateAvatar = async () => {
avatarLoading.value = true
try {
// TODO: 调用上传头像API
message.success('头像更新成功')
showAvatarModal.value = false
} catch (error) {
message.error('头像更新失败')
} finally {
avatarLoading.value = false
}
}
// 初始化数据
const initData = () => {
if (userInfo.value) {
profileForm.nickname = userInfo.value.nickname || ''
profileForm.email = userInfo.value.email || ''
profileForm.phone = userInfo.value.phone || ''
avatarUrl.value = userInfo.value.avatar || ''
}
}
onMounted(() => {
initData()
// TODO: 加载统计数据
})
import { User } from '@element-plus/icons-vue'
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
<style scoped>
.profile-page {
min-height: 100vh;
background: #f5f5f5;
background: linear-gradient(135deg, #43cea2 0%, #185a9d 100%);
background-attachment: fixed;
}
.page-header {
background: white;
border-bottom: 1px solid #e8e8e8;
padding: 0 16px;
position: sticky;
top: 0;
z-index: 100;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 56px;
max-width: 1200px;
margin: 0 auto;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #262626;
}
.back-btn, .logout-btn {
display: flex;
align-items: center;
gap: 4px;
color: #666;
&:hover {
color: #1890ff;
}
}
.profile-page .container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
margin-top: 2rem;
margin-bottom: 2rem;
}
.page-main {
padding: 24px 16px;
.container {
max-width: 800px;
margin: 0 auto;
}
}
.user-info-card {
margin-bottom: 24px;
.user-header {
display: flex;
gap: 20px;
align-items: flex-start;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
.user-avatar {
border: 2px solid #f0f0f0;
}
}
.user-details {
flex: 1;
.username {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: #262626;
}
.user-account {
margin: 0 0 8px 0;
color: #666;
font-size: 14px;
}
.user-status {
margin: 0;
}
}
}
.menu-section {
margin-bottom: 24px;
.menu-card {
margin-bottom: 16px;
.menu-list {
.menu-item {
display: flex;
align-items: center;
padding: 12px 0;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: all 0.2s;
&:last-child {
border-bottom: none;
}
&:hover {
background: #fafafa;
margin: 0 -16px;
padding: 12px 16px;
}
.menu-icon {
width: 20px;
color: #666;
margin-right: 12px;
}
.menu-text {
flex: 1;
color: #262626;
}
.menu-arrow {
color: #bfbfbf;
font-size: 12px;
}
}
}
}
}
.stats-card {
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
.stat-item {
text-align: center;
padding: 16px;
background: #fafafa;
border-radius: 8px;
.stat-value {
font-size: 24px;
font-weight: 600;
color: #1890ff;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: #666;
}
}
}
}
.avatar-upload {
display: flex;
justify-content: center;
:deep(.ant-upload-select) {
width: 120px !important;
height: 120px !important;
}
}
.about-content {
text-align: center;
padding: 20px;
.app-info {
h3 {
color: #1890ff;
margin-bottom: 16px;
}
p {
margin-bottom: 8px;
color: #666;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.page-main {
padding: 16px 12px;
}
.user-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
}
</style>
</style>
+426 -250
View File
@@ -1,97 +1,106 @@
<template>
<div class="register-page">
<div class="register-container">
<div class="register-card">
<!-- Logo和标题 -->
<div class="register-header">
<router-link to="/" class="logo">
<span class="logo-text">开心APP</span>
</router-link>
<h1 class="register-title">创建账户</h1>
<p class="register-subtitle">加入开心APP开始您的情绪陪伴之旅</p>
<div class="container mx-auto px-4 py-8">
<div class="max-w-md mx-auto card">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-white mb-2">加入情绪博物馆</h1>
<p class="text-white/80">只需几步开启你的情绪探索之旅</p>
</div>
<!-- 注册表单 -->
<a-form
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
@finish="handleRegister"
@finishFailed="handleRegisterFailed"
layout="vertical"
class="register-form"
label-width="0"
@submit.prevent="handleRegister"
>
<a-form-item label="账号" name="account">
<a-input
v-model:value="registerForm.account"
placeholder="请输入手机号或邮箱"
<el-form-item prop="nickname">
<el-input
v-model="registerForm.nickname"
placeholder="请输入昵称"
:prefix-icon="Avatar"
size="large"
:prefix="h(UserOutlined)"
autocomplete="off"
clearable
/>
</a-form-item>
</el-form-item>
<a-form-item label="密码" name="password">
<a-input-password
v-model:value="registerForm.password"
placeholder="请输入密码"
<el-form-item prop="account">
<el-input
v-model="registerForm.account"
placeholder="请输入账号(4-20位字母数字下划线)"
:prefix-icon="User"
size="large"
:prefix="h(LockOutlined)"
autocomplete="new-password"
clearable
@blur="checkAccountExists"
/>
</a-form-item>
</el-form-item>
<a-form-item label="确认密码" name="confirmPassword">
<a-input-password
v-model:value="registerForm.confirmPassword"
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="请输入密码(6-20位)"
:prefix-icon="Lock"
size="large"
show-password
clearable
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="请再次输入密码"
:prefix-icon="Lock"
size="large"
:prefix="h(LockOutlined)"
autocomplete="new-password"
show-password
clearable
/>
</a-form-item>
</el-form-item>
<!-- 验证码 -->
<a-form-item label="验证码" name="captcha">
<div class="captcha-container">
<a-input
v-model:value="registerForm.captcha"
<el-form-item prop="captcha">
<div class="flex gap-3">
<el-input
v-model="registerForm.captcha"
placeholder="请输入验证码"
:prefix-icon="Key"
size="large"
style="flex: 1"
clearable
class="flex-1"
/>
<div class="captcha-image" @click="refreshCaptcha">
<div class="captcha-container" @click="refreshCaptcha">
<img
v-if="captchaImage"
:src="captchaImage"
alt="验证码"
style="width: 100%; height: 100%; cursor: pointer;"
class="captcha-image"
/>
<div v-else class="captcha-loading">
<a-spin size="small" />
<el-icon class="is-loading"><Loading /></el-icon>
</div>
</div>
</div>
<div class="captcha-tip">点击图片刷新验证码</div>
</a-form-item>
</el-form-item>
<a-form-item>
<a-button
<el-form-item>
<el-button
type="primary"
html-type="submit"
size="large"
:loading="registerLoading"
class="register-button"
block
class="w-full register-btn"
:loading="loading"
@click="handleRegister"
>
注册
</a-button>
</a-form-item>
</a-form>
{{ loading ? '注册中...' : '立即注册' }}
</el-button>
</el-form-item>
</el-form>
<!-- 登录链接 -->
<div class="login-link">
已有账户
<router-link to="/login" class="login-btn">立即登录</router-link>
<div class="text-center mt-6">
<span class="text-white/70">已有账号</span>
<router-link to="/login" class="text-white font-medium hover:underline ml-1">
立即登录
</router-link>
</div>
</div>
</div>
@@ -99,219 +108,386 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
import { authService } from '@/services/auth'
import { useUserStore } from '@/stores/user'
import type { RegisterRequest } from '@/types/auth'
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import {
User,
UserFilled,
Avatar,
Message,
Phone,
Lock,
Key,
Loading
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import AuthService from '@/services/auth'
import { envConfig } from '@/config/env'
import type { RegisterRequest } from '@/types/auth'
import type { FormInstance, FormRules } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const router = useRouter()
const authStore = useAuthStore()
// 表单数据
const registerForm = reactive<RegisterRequest>({
account: '',
password: '',
confirmPassword: '',
captcha: ''
})
// 表单引用
const registerFormRef = ref<FormInstance>()
// 表单验证规则
const registerRules = {
account: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 3, message: '账号长度不能少于3位', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (_: any, value: string) => {
if (value !== registerForm.password) {
return Promise.reject('两次输入的密码不一致')
}
return Promise.resolve()
},
trigger: 'blur'
}
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 4, message: '验证码长度为4位', trigger: 'blur' }
]
// 加载状态
const loading = ref(false)
// 验证码相关
const captchaImage = ref('')
const captchaKey = ref('')
// 注册表单数据(简化版)
const registerForm = reactive({
account: '',
password: '',
confirmPassword: '',
nickname: '',
captcha: '',
captchaKey: ''
})
// 自定义验证规则
const validateAccount = (rule: any, value: string, callback: any) => {
if (!value) {
callback(new Error('请输入账号'))
return
}
if (!/^[a-zA-Z0-9_]{4,20}$/.test(value)) {
callback(new Error('账号只能包含字母、数字和下划线,长度4-20位'))
return
}
// 账号唯一性校验建议在@blur时单独提示,不在rules中异步校验
callback()
}
const validatePassword = (rule: any, value: string, callback: any) => {
if (!value) {
callback(new Error('请输入密码'))
return
}
if (value.length < 6 || value.length > 20) {
callback(new Error('密码长度必须在6-20位之间'))
return
}
callback()
}
const validateConfirmPassword = (rule: any, value: string, callback: any) => {
if (!value) {
callback(new Error('请再次输入密码'))
return
}
if (value !== registerForm.password) {
callback(new Error('两次输入的密码不一致'))
return
}
callback()
}
// 移除了邮箱和手机号验证,简化注册流程
// 表单验证规则(简化版)
const registerRules: FormRules = {
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 1, max: 20, message: '昵称长度必须在1-20位之间', trigger: 'blur' }
],
account: [{ validator: validateAccount, trigger: 'blur' }],
password: [{ validator: validatePassword, trigger: 'blur' }],
confirmPassword: [{ validator: validateConfirmPassword, trigger: 'blur' }],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ min: 4, max: 6, message: '验证码长度不正确', trigger: 'blur' }
]
}
/**
* 获取验证码
*/
const getCaptcha = async () => {
try {
const response = await AuthService.getCaptcha()
// 后端返回的数据已经包含了 data:image/png;base64, 前缀,直接使用
captchaImage.value = response.captchaImage
captchaKey.value = response.captchaKey
registerForm.captchaKey = response.captchaKey
} catch (error) {
console.error('获取验证码失败:', error)
ElMessage.error('获取验证码失败')
}
}
/**
* 刷新验证码
*/
const refreshCaptcha = () => {
registerForm.captcha = ''
getCaptcha()
}
/**
* 检查账号是否存在
*/
const checkAccountExists = async () => {
if (!registerForm.account || !/^[a-zA-Z0-9_]{4,20}$/.test(registerForm.account)) {
return
}
// 状态
const registerLoading = ref(false)
const captchaImage = ref('')
const captchaKey = ref('')
// 获取验证码
const getCaptcha = async () => {
try {
const response = await authService.getCaptcha()
console.log('验证码响应:', response)
captchaImage.value = response.captchaImage // 修正字段
captchaKey.value = response.captchaKey // 修正字段
console.log('验证码图片URL:', captchaImage.value.substring(0, 50) + '...')
} catch (error) {
console.error('获取验证码失败:', error)
message.error('获取验证码失败')
try {
const exists = await AuthService.checkAccountExists(registerForm.account)
if (exists) {
ElMessage.warning('该账号已存在')
}
} catch (error) {
console.error('检查账号失败:', error)
}
}
// 刷新验证码
const refreshCaptcha = () => {
getCaptcha()
}
// 移除了邮箱和手机号检查方法,简化注册流程
// 注册处理
const handleRegister = async (values: RegisterRequest) => {
registerLoading.value = true
try {
const registerData = {
...values,
captchaKey: captchaKey.value
}
const data = await authService.register(registerData)
message.success('注册成功,已自动登录')
userStore.setToken(data.accessToken)
userStore.setUserInfo(data.userInfo)
/**
* 处理注册
*/
const handleRegister = async () => {
if (!registerFormRef.value) return
try {
console.log('开始注册流程...')
console.log('注册表单数据:', registerForm)
// 表单验证
await registerFormRef.value.validate()
console.log('表单验证通过')
loading.value = true
// 构造注册请求数据
const registerData = {
account: registerForm.account,
password: registerForm.password,
confirmPassword: registerForm.confirmPassword,
username: registerForm.nickname, // 使用昵称作为用户名
nickname: registerForm.nickname,
email: '', // 暂时为空,后续在个人页面完善
phone: '', // 暂时为空,后续在个人页面完善
captcha: registerForm.captcha,
captchaKey: registerForm.captchaKey
}
// 调用注册接口
console.log('调用注册接口...')
const success = await authStore.register(registerData)
console.log('注册结果:', success)
if (success) {
// 注册成功,跳转到首页
console.log('注册成功,跳转到首页')
ElMessage.success('注册成功,欢迎加入情绪博物馆!')
router.push('/')
} catch (error: any) {
message.error(error.message || '注册失败,请稍后重试')
} else {
// 注册失败,刷新验证码
console.log('注册失败,刷新验证码')
refreshCaptcha()
} finally {
registerLoading.value = false
}
}
} catch (error: any) {
console.error('注册过程中发生错误:', error)
// 注册失败处理
const handleRegisterFailed = (errorInfo: any) => {
console.log('Register failed:', errorInfo)
}
// 根据错误类型显示不同的提示
let errorMessage = '注册失败,请稍后重试'
if (error.message) {
if (error.message.includes('账号已存在')) {
errorMessage = '该账号已被注册,请更换账号或直接登录'
} else if (error.message.includes('邮箱已存在')) {
errorMessage = '该邮箱已被注册,请更换邮箱或直接登录'
} else if (error.message.includes('手机号已存在')) {
errorMessage = '该手机号已被注册,请更换手机号或直接登录'
} else if (error.message.includes('验证码')) {
errorMessage = '验证码错误,请重新输入'
} else {
errorMessage = error.message
}
}
// 初始化
onMounted(() => {
getCaptcha()
})
ElMessage.error(errorMessage)
// 刷新验证码
refreshCaptcha()
} finally {
loading.value = false
}
}
// 组件挂载时获取验证码
onMounted(() => {
getCaptcha()
})
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
<style scoped>
.register-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-attachment: fixed;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.container {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
padding: 3rem 2.5rem;
width: 100%;
max-width: 420px;
}
.card {
background: transparent;
border: none;
box-shadow: none;
}
.captcha-container {
width: 140px;
height: 48px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.captcha-container:hover {
border-color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.15);
}
.captcha-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
}
.captcha-loading {
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
}
:deep(.el-input--large .el-input__wrapper) {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
box-shadow: none;
transition: all 0.3s ease;
padding: 0 16px;
height: 48px;
}
:deep(.el-input--large .el-input__wrapper:hover) {
border-color: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.15);
}
:deep(.el-input--large .el-input__wrapper.is-focus) {
border-color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
}
:deep(.el-input--large .el-input__inner) {
color: white;
font-size: 16px;
}
:deep(.el-input--large .el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.6);
}
:deep(.el-input--large .el-input__prefix) {
color: rgba(255, 255, 255, 0.7);
}
:deep(.el-input--large .el-input__suffix) {
color: rgba(255, 255, 255, 0.7);
}
.register-btn {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
border: none;
border-radius: 12px;
font-weight: 600;
font-size: 16px;
height: 48px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.3);
}
:deep(.register-btn:hover) {
background: linear-gradient(135deg, #ff5252 0%, #d63031 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(255, 107, 107, 0.4);
}
:deep(.register-btn:active) {
transform: translateY(0);
}
:deep(.el-form-item) {
margin-bottom: 24px;
}
:deep(.el-form-item__error) {
color: #ffcdd2;
background: rgba(255, 107, 107, 0.1);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
margin-top: 4px;
}
/* 响应式设计 */
@media (max-width: 640px) {
.register-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
padding: 1rem 0.5rem;
}
.register-container {
width: 100%;
max-width: 400px;
.container {
padding: 2rem 1.5rem;
border-radius: 20px;
max-width: 100%;
}
.register-card {
background: white;
border-radius: 16px;
padding: 40px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
.captcha-container {
width: 120px;
height: 44px;
}
}
.register-header {
text-align: center;
margin-bottom: 32px;
.logo {
display: inline-block;
text-decoration: none;
color: #4A90E2;
font-size: 24px;
font-weight: bold;
margin-bottom: 16px;
}
.register-title {
font-size: 28px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.register-subtitle {
color: #888;
font-size: 14px;
margin: 0;
}
/* 动画效果 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
.register-form {
.captcha-container {
display: flex;
gap: 12px;
align-items: center;
}
.captcha-image {
width: 100px;
height: 40px;
border: 1px solid #d9d9d9;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
cursor: pointer;
transition: border-color 0.3s;
&:hover {
border-color: #4A90E2;
}
}
.captcha-loading {
display: flex;
align-items: center;
justify-content: center;
}
.captcha-tip {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.register-button {
background: linear-gradient(135deg, #4A90E2 0%, #5BA0F2 100%);
border: none;
border-radius: 8px;
font-weight: 600;
height: 48px;
font-size: 16px;
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-link {
text-align: center;
margin-top: 24px;
color: #888;
font-size: 14px;
.login-btn {
color: #4A90E2;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
}
</style>
.container {
animation: fadeInUp 0.6s ease-out;
}
</style>
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
<template>
<div class="social-page">
<div class="container mx-auto px-4 py-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">社交分享</h1>
<p class="text-gray-600">与朋友分享情绪故事获得支持</p>
</div>
<div class="max-w-4xl mx-auto">
<!-- 发布动态 -->
<div class="card mb-6">
<h2 class="text-xl font-semibold mb-4">发布动态</h2>
<div class="space-y-4">
<el-input
v-model="postContent"
type="textarea"
:rows="4"
placeholder="分享你的情绪故事..."
/>
<div class="flex justify-between items-center">
<el-button type="primary" @click="publishPost">
发布动态
</el-button>
<div class="text-sm text-gray-500">
还可以输入 {{ 500 - postContent.length }}
</div>
</div>
</div>
</div>
<!-- 动态列表 -->
<div class="card">
<h2 class="text-xl font-semibold mb-4">最新动态</h2>
<div class="text-center text-gray-500 py-12">
<el-icon class="text-4xl mb-4">
<Share />
</el-icon>
<p>社交动态功能开发中...</p>
<p class="text-sm mt-2">这里将显示用户发布的情绪动态</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Share } from '@element-plus/icons-vue'
const postContent = ref('')
const publishPost = () => {
// TODO: 实现发布动态的逻辑
console.log('发布动态:', postContent.value)
postContent.value = ''
}
</script>
<style scoped>
.social-page {
min-height: 100vh;
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
background-attachment: fixed;
}
.social-page .container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
margin-top: 2rem;
margin-bottom: 2rem;
}
</style>
+258 -659
View File
@@ -1,700 +1,299 @@
<template>
<div class="topic-tracker-page">
<!-- 头部 -->
<header class="page-header">
<div class="header-content">
<div class="header-left">
<a-button type="text" @click="$router.back()" class="back-btn">
<ArrowLeftOutlined />
</a-button>
<h1 class="page-title">话题追踪</h1>
<div class="topic-tracker-page antialiased flex flex-col min-h-screen">
<!-- Header -->
<header class="bg-white shadow-md z-10 flex-shrink-0">
<div class="container mx-auto px-4 py-3 flex items-center justify-between relative">
<div class="flex items-center space-x-4">
<router-link to="/" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="home" class="w-6 h-6"></i>
</router-link>
<router-link to="/messages" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="bell" class="w-6 h-6"></i>
</router-link>
</div>
<a-button type="primary" @click="showNewTopicModal = true" class="new-topic-btn">
<PlusOutlined />
新建话题
</a-button>
<h1 class="text-lg font-bold text-text-dark absolute left-1/2 -translate-x-1/2">话题追踪</h1>
<router-link to="/settings" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="user" class="w-6 h-6"></i>
</router-link>
</div>
</header>
<!-- 主要内容 -->
<main class="page-main">
<div class="container">
<!-- 筛选和搜索 -->
<div class="filter-section">
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索话题..."
style="width: 300px"
@search="handleSearch"
/>
<a-select
v-model:value="statusFilter"
placeholder="状态筛选"
style="width: 120px"
@change="handleFilterChange"
>
<a-select-option value="">全部</a-select-option>
<a-select-option value="active">进行中</a-select-option>
<a-select-option value="completed">已完成</a-select-option>
<a-select-option value="paused">已暂停</a-select-option>
</a-select>
<main class="flex-grow pt-8 pb-28">
<div class="container mx-auto px-6">
<div class="text-center mb-12 animate-fade-in-up">
<h1 class="text-3xl md:text-4xl font-bold text-text-dark">洞察你的思绪整理你的生活</h1>
<p class="text-base text-text-medium mt-3 max-w-2xl mx-auto">开开会自动梳理你最近关心的事你也可以手动创建任何想追踪的话题见证自己的思考与成长</p>
</div>
<!-- 话题列表 -->
<div class="topics-grid">
<div
v-for="topic in filteredTopics"
:key="topic.id"
class="topic-card"
@click="viewTopicDetail(topic)"
>
<a-card :hoverable="true">
<div class="topic-header">
<div class="topic-status">
<a-tag :color="getStatusColor(topic.status)">
{{ getStatusText(topic.status) }}
</a-tag>
</div>
<a-dropdown @click.stop>
<a-button type="text" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="editTopic(topic)">
<EditOutlined />
编辑
</a-menu-item>
<a-menu-item @click="toggleTopicStatus(topic)">
<PlayCircleOutlined v-if="topic.status === 'paused'" />
<PauseCircleOutlined v-else />
{{ topic.status === 'paused' ? '继续' : '暂停' }}
</a-menu-item>
<a-menu-item @click="deleteTopic(topic.id)" danger>
<DeleteOutlined />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="topic-content">
<h3 class="topic-title">{{ topic.title }}</h3>
<p class="topic-description" v-if="topic.description">
{{ topic.description }}
</p>
<!-- 进度条 -->
<div class="topic-progress" v-if="topic.progress !== undefined">
<div class="progress-info">
<span class="progress-label">进度</span>
<span class="progress-value">{{ topic.progress }}%</span>
<div class="grid grid-cols-1 lg:grid-cols-5 gap-12">
<div class="lg:col-span-3 animate-fade-in-up" style="animation-delay: 0.2s;">
<div class="flex items-center mb-6 space-x-3">
<i data-lucide="brain-circuit" class="w-8 h-8 text-tech-blue"></i>
<h2 class="text-2xl font-bold text-text-dark">AI 自动总结</h2>
</div>
<div id="ai-summary-list" class="space-y-6">
<!-- AI总结的话题将在这里显示 -->
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-200/50">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center space-x-3">
<div class="w-3 h-3 bg-tech-blue rounded-full"></div>
<h3 class="text-lg font-semibold text-text-dark">工作计划</h3>
</div>
<a-progress
:percent="topic.progress"
:stroke-color="getStatusColor(topic.status)"
:show-info="false"
size="small"
/>
<span class="text-xs text-text-medium bg-gray-100 px-2 py-1 rounded-full">AI 总结</span>
</div>
<!-- 标签 -->
<div class="topic-tags" v-if="topic.tags && topic.tags.length">
<a-tag
v-for="tag in topic.tags.slice(0, 3)"
:key="tag"
size="small"
class="topic-tag"
>
{{ tag }}
</a-tag>
<span v-if="topic.tags.length > 3" class="more-tags">
+{{ topic.tags.length - 3 }}
</span>
</div>
<!-- 时间信息 -->
<div class="topic-meta">
<span class="topic-date">
<CalendarOutlined />
{{ formatTime.friendly(topic.createTime) }}
</span>
<span class="topic-update" v-if="topic.updateTime !== topic.createTime">
更新于 {{ formatTime.friendly(topic.updateTime) }}
</span>
<p class="text-text-medium text-sm mb-4">基于你最近的对话开开发现你在关注工作效率和时间管理相关的话题</p>
<div class="space-y-3">
<div class="border-l-2 border-tech-blue pl-4">
<p class="text-sm text-text-dark">讨论了新项目的时间安排</p>
<span class="text-xs text-text-medium">3天前</span>
</div>
<div class="border-l-2 border-tech-blue pl-4">
<p class="text-sm text-text-dark">分享了提高工作效率的方法</p>
<span class="text-xs text-text-medium">1周前</span>
</div>
</div>
</div>
</a-card>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredTopics.length === 0" class="empty-state">
<a-empty
description="暂无话题记录"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
>
<a-button type="primary" @click="showNewTopicModal = true">
创建第一个话题
</a-button>
</a-empty>
<div class="lg:col-span-2 animate-fade-in-up" style="animation-delay: 0.4s;">
<div class="bg-white p-6 sm:p-8 rounded-2xl shadow-lg border border-gray-200/50 scroll-mt-24">
<div class="flex items-center mb-6 space-x-3">
<i data-lucide="plus-circle" class="w-8 h-8 text-warm-orange"></i>
<h2 class="text-2xl font-bold text-text-dark">我的话题</h2>
</div>
<form @submit.prevent="createTopic" class="space-y-4">
<div>
<label for="topic-title" class="block text-sm font-medium text-text-medium mb-1">话题标题</label>
<input
type="text"
id="topic-title"
v-model="newTopic.title"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-tech-blue focus:border-transparent transition"
placeholder="例如:暑期健身计划"
required
>
</div>
<div>
<label for="topic-content" class="block text-sm font-medium text-text-medium mb-1">初始内容</label>
<textarea
id="topic-content"
v-model="newTopic.content"
rows="4"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-tech-blue focus:border-transparent transition"
placeholder="写下你的计划、想法或任何琐事..."
required
></textarea>
</div>
<button
type="submit"
class="w-full bg-warm-orange text-white px-5 py-3 rounded-lg font-semibold hover:bg-orange-600 transition-all duration-300 transform hover:scale-105 shadow-lg shadow-orange-500/30"
>
创建新话题
</button>
</form>
<div class="mt-8 border-t border-gray-200 pt-6">
<div class="flex items-center mb-4 space-x-3">
<i data-lucide="list" class="w-6 h-6 text-text-medium"></i>
<h3 class="text-xl font-semibold text-text-dark">已创建的话题</h3>
</div>
<div class="space-y-4 max-h-96 overflow-y-auto pr-2">
<!-- 用户创建的话题列表 -->
<div
v-for="topic in userTopics"
:key="topic.id"
@click="openTopicDetail(topic)"
class="p-4 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
>
<h4 class="font-semibold text-text-dark text-sm">{{ topic.title }}</h4>
<p class="text-xs text-text-medium mt-1">{{ topic.date }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 新建话题模态框 -->
<a-modal
v-model:open="showNewTopicModal"
title="新建话题"
@ok="createTopic"
@cancel="resetTopicForm"
:confirm-loading="isCreating"
width="600px"
<!-- App Navigation -->
<BottomNavigation />
<!-- Topic Detail Modal -->
<div
v-if="showTopicModal"
class="fixed inset-0 bg-black bg-opacity-60 backdrop-blur-sm z-50 flex items-center justify-center p-4"
@click="closeTopicModal"
>
<a-form :model="topicForm" layout="vertical">
<a-form-item label="话题标题" required>
<a-input
v-model:value="topicForm.title"
placeholder="请输入话题标题"
:maxlength="50"
show-count
/>
</a-form-item>
<a-form-item label="话题描述">
<a-textarea
v-model:value="topicForm.description"
placeholder="请输入话题描述(可选)"
:rows="3"
:maxlength="200"
show-count
/>
</a-form-item>
<a-form-item label="标签">
<div class="tags-input-section">
<a-input
v-model:value="newTagInput"
placeholder="添加标签,按回车确认"
@press-enter="addTag"
style="margin-bottom: 8px"
/>
<div class="selected-tags" v-if="topicForm.tags.length">
<a-tag
v-for="tag in topicForm.tags"
:key="tag"
closable
@close="removeTag(tag)"
color="blue"
>
{{ tag }}
</a-tag>
<div
class="bg-light-gray rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col"
@click.stop
>
<div class="p-5 border-b bg-white rounded-t-2xl flex justify-between items-start">
<div>
<h2 class="text-2xl font-bold text-text-dark">{{ selectedTopic?.title }}</h2>
<p class="text-sm text-text-medium">{{ selectedTopic?.date }}</p>
</div>
<button
@click="closeTopicModal"
class="text-text-medium hover:text-tech-blue transition-colors p-1"
>
<i data-lucide="x" class="w-7 h-7"></i>
</button>
</div>
<div class="p-6 overflow-y-auto flex-1">
<div class="space-y-8">
<div class="border-l-2 border-tech-blue pl-4">
<p class="text-sm text-text-dark">{{ selectedTopic?.content }}</p>
<span class="text-xs text-text-medium">{{ selectedTopic?.date }}</span>
</div>
</div>
</a-form-item>
<a-form-item label="初始进度">
<a-slider
v-model:value="topicForm.progress"
:min="0"
:max="100"
:marks="{ 0: '0%', 25: '25%', 50: '50%', 75: '75%', 100: '100%' }"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 话题详情模态框 -->
<a-modal
v-model:open="showDetailModal"
:title="selectedTopic?.title"
:footer="null"
width="800px"
>
<div v-if="selectedTopic" class="topic-detail">
<div class="detail-header">
<a-tag :color="getStatusColor(selectedTopic.status)" size="large">
{{ getStatusText(selectedTopic.status) }}
</a-tag>
<span class="detail-date">
创建于 {{ formatTime.standard(selectedTopic.createTime) }}
</span>
</div>
<div class="detail-description" v-if="selectedTopic.description">
<h4>描述</h4>
<p>{{ selectedTopic.description }}</p>
</div>
<div class="detail-progress" v-if="selectedTopic.progress !== undefined">
<h4>进度</h4>
<a-progress
:percent="selectedTopic.progress"
:stroke-color="getStatusColor(selectedTopic.status)"
/>
</div>
<div class="detail-tags" v-if="selectedTopic.tags && selectedTopic.tags.length">
<h4>标签</h4>
<div class="tags-list">
<a-tag
v-for="tag in selectedTopic.tags"
:key="tag"
color="blue"
<div class="p-6 border-t bg-white rounded-b-2xl">
<h3 class="text-base font-semibold text-text-dark mb-2">添加新进展</h3>
<form @submit.prevent="addTopicEntry" class="flex items-start space-x-3">
<textarea
v-model="newEntry"
rows="2"
class="flex-1 w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-tech-blue transition-shadow text-sm"
placeholder="为这个话题添加新进展..."
></textarea>
<button
type="submit"
class="bg-tech-blue text-white rounded-lg px-4 py-2 h-full font-semibold hover:bg-blue-600 transition-colors flex-shrink-0"
>
{{ tag }}
</a-tag>
</div>
</div>
<div class="detail-actions">
<a-button type="primary" @click="editTopic(selectedTopic)">
<EditOutlined />
编辑话题
</a-button>
<a-button @click="toggleTopicStatus(selectedTopic)">
<PlayCircleOutlined v-if="selectedTopic.status === 'paused'" />
<PauseCircleOutlined v-else />
{{ selectedTopic.status === 'paused' ? '继续追踪' : '暂停追踪' }}
</a-button>
<i data-lucide="plus" class="w-5 h-5"></i>
</button>
</form>
</div>
</div>
</a-modal>
</div>
<!-- 底部导航栏 -->
<BottomNavigation />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import {
ArrowLeftOutlined,
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
CalendarOutlined,
} from '@ant-design/icons-vue'
import { Empty, message } from 'ant-design-vue'
import { formatTime } from '@/utils'
import type { Topic } from '@/types'
import { ref, onMounted } from 'vue'
import BottomNavigation from '@/components/layout/BottomNavigation.vue'
// 响应式数据
const showNewTopicModal = ref(false)
const showDetailModal = ref(false)
const isCreating = ref(false)
const searchKeyword = ref('')
const statusFilter = ref('')
const newTagInput = ref('')
const selectedTopic = ref<Topic | null>(null)
// 响应式数据
const newTopic = ref({
title: '',
content: ''
})
// 话题数据
const topics = ref<Topic[]>([
{
id: '1',
title: '学习Vue 3',
description: '深入学习Vue 3的新特性和最佳实践',
tags: ['前端', '学习', 'Vue'],
createTime: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
updateTime: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
status: 'active',
progress: 65
},
{
id: '2',
title: '健身计划',
description: '制定并执行每周3次的健身计划',
tags: ['健康', '运动', '计划'],
createTime: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(),
updateTime: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
status: 'active',
progress: 40
},
{
id: '3',
title: '读书笔记',
description: '阅读《深度工作》并记录读书笔记',
tags: ['阅读', '笔记', '自我提升'],
createTime: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
updateTime: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
status: 'completed',
progress: 100
const userTopics = ref([
{
id: 1,
title: '暑期健身计划',
content: '制定了详细的健身计划,包括每周的运动安排和饮食控制。',
date: '2024年1月15日'
},
{
id: 2,
title: '学习新技能',
content: '开始学习Vue.js和TypeScript,希望能在3个月内掌握基础知识。',
date: '2024年1月10日'
}
])
const showTopicModal = ref(false)
const selectedTopic = ref(null)
const newEntry = ref('')
// 创建新话题
const createTopic = () => {
if (newTopic.value.title.trim() && newTopic.value.content.trim()) {
const topic = {
id: Date.now(),
title: newTopic.value.title,
content: newTopic.value.content,
date: new Date().toLocaleDateString('zh-CN')
}
])
// 表单数据
const topicForm = reactive({
title: '',
description: '',
tags: [] as string[],
progress: 0
})
// 计算属性
const filteredTopics = computed(() => {
let result = topics.value
// 关键词搜索
if (searchKeyword.value) {
result = result.filter(topic =>
topic.title.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
topic.description?.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
topic.tags?.some(tag => tag.toLowerCase().includes(searchKeyword.value.toLowerCase()))
)
}
// 状态筛选
if (statusFilter.value) {
result = result.filter(topic => topic.status === statusFilter.value)
}
return result
})
// 方法
const getStatusColor = (status: string) => {
const colors = {
active: 'blue',
completed: 'green',
paused: 'orange'
}
return colors[status as keyof typeof colors] || 'default'
userTopics.value.unshift(topic)
newTopic.value = { title: '', content: '' }
console.log('创建新话题:', topic)
}
}
const getStatusText = (status: string) => {
const texts = {
active: '进行中',
completed: '已完成',
paused: '已暂停'
}
return texts[status as keyof typeof texts] || status
// 打开话题详情
const openTopicDetail = (topic: any) => {
selectedTopic.value = topic
showTopicModal.value = true
}
// 关闭话题详情
const closeTopicModal = () => {
showTopicModal.value = false
selectedTopic.value = null
newEntry.value = ''
}
// 添加话题进展
const addTopicEntry = () => {
if (newEntry.value.trim()) {
// TODO: 实现添加话题进展逻辑
console.log('添加话题进展:', newEntry.value)
newEntry.value = ''
}
}
const handleSearch = () => {
// 搜索逻辑已在计算属性中处理
// 生命周期
onMounted(() => {
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
const handleFilterChange = () => {
// 筛选逻辑已在计算属性中处理
}
const createTopic = async () => {
if (!topicForm.title.trim()) {
message.warning('请输入话题标题')
return
}
isCreating.value = true
try {
const newTopic: Topic = {
id: Date.now().toString(),
title: topicForm.title.trim(),
description: topicForm.description.trim() || undefined,
tags: topicForm.tags.length ? topicForm.tags : undefined,
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
status: 'active',
progress: topicForm.progress
}
topics.value.unshift(newTopic)
message.success('话题创建成功')
showNewTopicModal.value = false
resetTopicForm()
} catch (error) {
message.error('创建失败,请重试')
} finally {
isCreating.value = false
}
}
const resetTopicForm = () => {
topicForm.title = ''
topicForm.description = ''
topicForm.tags = []
topicForm.progress = 0
newTagInput.value = ''
}
const addTag = () => {
const tag = newTagInput.value.trim()
if (tag && !topicForm.tags.includes(tag)) {
topicForm.tags.push(tag)
newTagInput.value = ''
}
}
const removeTag = (tag: string) => {
const index = topicForm.tags.indexOf(tag)
if (index > -1) {
topicForm.tags.splice(index, 1)
}
}
const viewTopicDetail = (topic: Topic) => {
selectedTopic.value = topic
showDetailModal.value = true
}
const editTopic = (_topic: Topic) => {
// TODO: 实现编辑功能
message.info('编辑功能开发中...')
}
const toggleTopicStatus = (topic: Topic) => {
if (topic.status === 'active') {
topic.status = 'paused'
message.success('话题已暂停')
} else if (topic.status === 'paused') {
topic.status = 'active'
message.success('话题已继续')
}
topic.updateTime = new Date().toISOString()
}
const deleteTopic = (id: string) => {
const index = topics.value.findIndex(t => t.id === id)
if (index > -1) {
topics.value.splice(index, 1)
message.success('话题删除成功')
}
}
// 组件挂载
onMounted(() => {
// 初始化数据加载
})
})
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.topic-tracker-page {
min-height: 100vh;
background: $light-gray;
<style scoped>
/* 导入原始样式变量 */
:root {
--tech-blue: #4A90E2;
--warm-orange: #F5A623;
--white: #FFFFFF;
--light-gray: #F7F8FA;
--text-dark: #333333;
--text-medium: #888888;
}
/* 应用原始样式类 */
.bg-tech-blue { background-color: var(--tech-blue); }
.bg-warm-orange { background-color: var(--warm-orange); }
.bg-light-gray { background-color: var(--light-gray); }
.text-tech-blue { color: var(--tech-blue); }
.text-text-dark { color: var(--text-dark); }
.text-text-medium { color: var(--text-medium); }
.border-tech-blue { border-color: var(--tech-blue); }
.topic-tracker-page {
font-family: 'Noto Sans SC', sans-serif;
background-color: var(--light-gray);
color: var(--text-dark);
}
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out forwards;
opacity: 0;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
.page-header {
background: white;
box-shadow: $shadow-sm;
position: sticky;
top: 0;
z-index: 10;
to {
opacity: 1;
transform: translateY(0);
}
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
max-width: 1200px;
margin: 0 auto;
}
.header-left {
display: flex;
align-items: center;
gap: $spacing-md;
}
.back-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
.page-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-dark;
margin: 0;
}
.new-topic-btn {
border-radius: $border-radius-full;
}
.page-main {
padding: $spacing-lg;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.filter-section {
display: flex;
gap: $spacing-md;
margin-bottom: $spacing-xl;
flex-wrap: wrap;
}
.topics-grid {
display: grid;
grid-template-columns: 1fr;
gap: $spacing-lg;
@media (min-width: $breakpoint-md) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: $breakpoint-lg) {
grid-template-columns: repeat(3, 1fr);
}
}
.topic-card {
cursor: pointer;
transition: transform $transition-normal;
&:hover {
transform: translateY(-2px);
}
.topic-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
}
.topic-content {
.topic-title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: $spacing-sm;
line-height: 1.4;
}
.topic-description {
color: $text-medium;
line-height: 1.5;
margin-bottom: $spacing-md;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.topic-progress {
margin-bottom: $spacing-md;
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-xs;
.progress-label {
font-size: $font-size-sm;
color: $text-medium;
}
.progress-value {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-dark;
}
}
}
.topic-tags {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
margin-bottom: $spacing-md;
.topic-tag {
margin: 0;
}
.more-tags {
font-size: $font-size-sm;
color: $text-medium;
}
}
.topic-meta {
display: flex;
flex-direction: column;
gap: $spacing-xs;
font-size: $font-size-sm;
color: $text-medium;
.topic-date {
display: flex;
align-items: center;
gap: $spacing-xs;
}
}
}
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: $spacing-xxl;
}
// 模态框样式
.tags-input-section {
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
}
.topic-detail {
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 1px solid #f0f0f0;
.detail-date {
color: $text-medium;
font-size: $font-size-sm;
}
}
.detail-description,
.detail-progress,
.detail-tags {
margin-bottom: $spacing-lg;
h4 {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: $spacing-sm;
}
p {
color: $text-dark;
line-height: 1.6;
margin: 0;
}
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
.detail-actions {
display: flex;
gap: $spacing-md;
padding-top: $spacing-md;
border-top: 1px solid #f0f0f0;
}
}
</style>
/* 全局样式 */
body {
font-family: 'Noto Sans SC', sans-serif;
}
</style>