feat: 项目初始化及当前全部内容提交

This commit is contained in:
2025-07-15 17:37:50 +08:00
parent ec817067f1
commit e78f192d34
622 changed files with 75174 additions and 383 deletions
+81
View File
@@ -0,0 +1,81 @@
<template>
<div id="app">
<router-view />
<!-- 环境信息组件仅在非生产环境显示 -->
<EnvInfo />
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { debugLog } from '@/config/env'
import EnvInfo from '@/components/EnvInfo.vue'
const userStore = useUserStore()
onMounted(() => {
try {
// 初始化用户信息
userStore.initUser()
debugLog('App.vue loaded successfully, user:', userStore.userInfo)
} catch (error) {
console.error('App.vue 初始化失败:', error)
// 确保有一个默认的访客用户状态
userStore.setGuestMode()
}
})
</script>
<style lang="scss">
#app {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
// 全局滚动条样式
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
&:hover {
background: rgba(255, 255, 255, 0.5);
}
}
// 自定义动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from {
transform: translateY(20px);
opacity: 0;
}
.slide-up-leave-to {
transform: translateY(-20px);
opacity: 0;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
import request from './request'
/**
* 验证码相关API
*/
export const captchaApi = {
// 生成图形验证码
generate(type = 'arithmetic') {
return request.get('/captcha/generate', {
params: { type }
})
},
// 验证图形验证码
verify(captchaId, captcha) {
return request.post('/captcha/verify', null, {
params: { captchaId, captcha }
})
},
// 生成滑块验证码
generateSlider() {
return request.get('/captcha/slider/generate')
},
// 验证滑块验证码
verifySlider(data) {
return request.post('/captcha/slider/verify', data)
}
}
+121
View File
@@ -0,0 +1,121 @@
import request from './request'
/**
* AI聊天相关API
* 对应后端 AiChatController (/api/ai/chat)
*/
export const chatApi = {
// 创建会话
createConversation(data) {
return request.post('/ai/chat/conversation/create', data)
},
// 发送聊天消息
sendMessage(data) {
return request.post('/ai/chat/send', data)
},
// 流式聊天
streamChat(data) {
return request.post('/ai/chat/stream', data)
},
// 情绪分析
analyzeEmotion(data) {
return request.post('/ai/chat/emotion/analyze', data)
},
// 获取用户会话列表
getConversations(userId, pageNum = 1, pageSize = 20) {
return request.get(`/ai/chat/conversations/${userId}`, {
params: { pageNum, pageSize }
})
},
// 获取会话详情
getConversation(conversationId) {
return request.get(`/ai/chat/conversation/${conversationId}`)
},
// 获取会话消息列表
getMessages(conversationId, pageNum = 1, pageSize = 50) {
return request.get(`/ai/chat/conversation/${conversationId}/messages`, {
params: { pageNum, pageSize }
})
},
// 结束会话
endConversation(conversationId) {
return request.put(`/ai/chat/conversation/${conversationId}/end`)
},
// 删除会话
deleteConversation(conversationId) {
return request.delete(`/ai/chat/conversation/${conversationId}`)
},
// 标记消息已读
markMessageAsRead(messageId) {
return request.put(`/ai/chat/message/${messageId}/read`)
},
// 标记会话所有消息已读
markConversationAsRead(conversationId) {
return request.put(`/ai/chat/conversation/${conversationId}/read`)
},
// 健康检查
healthCheck() {
return request.get('/ai/chat/health')
},
// 获取AI服务信息
getServiceInfo() {
return request.get('/ai/chat/info')
}
}
/**
* 访客聊天相关API
* 对应后端 GuestChatController (/api/ai/guest)
*/
export const guestChatApi = {
// 访客聊天
guestChat(data) {
return request.post('/ai/guest/chat', data)
},
// 获取访客会话列表
getGuestConversations(pageNum = 1, pageSize = 20) {
return request.get('/ai/guest/conversations', {
params: { pageNum, pageSize }
})
},
// 获取访客会话消息
getGuestConversationMessages(conversationId, pageNum = 1, pageSize = 50) {
return request.get(`/ai/guest/conversation/${conversationId}/messages`, {
params: { pageNum, pageSize }
})
},
// 结束访客会话
endGuestConversation(conversationId) {
return request.post(`/ai/guest/conversation/${conversationId}/end`)
},
// 获取访客用户信息
getGuestUserInfo() {
return request.get('/ai/guest/user/info')
},
// 访客情绪分析
analyzeGuestEmotion(data) {
return request.post('/ai/guest/emotion/analyze', data)
},
// 访客服务健康检查
guestHealthCheck() {
return request.get('/ai/guest/health')
}
}
+23
View File
@@ -0,0 +1,23 @@
import request from './request'
/**
* 第三方登录相关API
*/
export const oauthApi = {
// 获取第三方登录授权URL
getAuthUrl(platform) {
return request.get(`/oauth/auth-url/${platform}`)
},
// 第三方登录
login(data) {
return request.post('/oauth/login', data)
},
// 获取第三方用户信息
getUserInfo(platform, code, state) {
return request.get(`/oauth/user-info/${platform}`, {
params: { code, state }
})
}
}
+116
View File
@@ -0,0 +1,116 @@
import axios from 'axios'
import { message } from 'ant-design-vue'
import { ENV_CONFIG, debugLog } from '@/config/env'
import { AuthUtils } from '@/utils/auth'
// 创建axios实例
const request = axios.create({
baseURL: ENV_CONFIG.API_BASE_URL,
timeout: ENV_CONFIG.API_TIMEOUT,
headers: {
'Content-Type': 'application/json'
}
})
// 打印环境信息
if (ENV_CONFIG.DEBUG_MODE) {
console.log('=== API配置信息 ===')
console.log('Base URL:', ENV_CONFIG.API_BASE_URL)
console.log('Timeout:', ENV_CONFIG.API_TIMEOUT)
console.log('Environment:', ENV_CONFIG.APP_ENV)
console.log('================')
}
// 请求拦截器
request.interceptors.request.use(
async (config) => {
// 自动刷新token(如果需要)
const token = await AuthUtils.autoRefreshToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 使用环境配置的调试日志
debugLog('发送请求:', config.method?.toUpperCase(), config.url, config.data || config.params)
return config
},
(error) => {
debugLog('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
const { data } = response
debugLog('收到响应:', response.config.url, data)
// 统一处理响应格式
if (data.code === 200) {
return {
success: true,
data: data.data,
message: data.message
}
} else {
// 业务错误
const errorMsg = data.message || '请求失败'
message.error(errorMsg)
return {
success: false,
data: null,
message: errorMsg
}
}
},
(error) => {
debugLog('响应错误:', error)
let errorMsg = '网络错误'
if (error.response) {
const { status, data } = error.response
switch (status) {
case 400:
errorMsg = data.message || '请求参数错误'
break
case 401:
errorMsg = '未授权,请重新登录'
// 处理登录过期
AuthUtils.clearTokens()
// 跳转到登录页
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
break
case 403:
errorMsg = '拒绝访问'
break
case 404:
errorMsg = '请求的资源不存在'
break
case 500:
errorMsg = '服务器内部错误'
break
default:
errorMsg = data.message || `请求失败 (${status})`
}
} else if (error.request) {
errorMsg = '网络连接失败,请检查网络'
} else {
errorMsg = error.message || '请求配置错误'
}
message.error(errorMsg)
return {
success: false,
data: null,
message: errorMsg
}
}
)
export default request
+302
View File
@@ -0,0 +1,302 @@
import { userApi } from './user'
import { chatApi, guestChatApi } from './chat'
import { debugLog } from '@/config/env'
/**
* API测试工具
* 用于测试前后端接口连通性
*/
export const apiTest = {
// 测试用户服务
async testUserService() {
debugLog('开始测试用户服务...')
try {
// 测试检查账号接口
const accountResult = await userApi.checkAccount('test_user')
debugLog('检查账号接口测试:', accountResult)
return {
success: true,
message: '用户服务连接正常',
data: accountResult
}
} catch (error) {
debugLog('用户服务测试失败:', error)
return {
success: false,
message: '用户服务连接失败',
error: error.message
}
}
},
// 测试AI服务
async testAiService() {
debugLog('开始测试AI服务...')
try {
// 测试健康检查接口
const healthResult = await chatApi.healthCheck()
debugLog('AI服务健康检查:', healthResult)
// 测试服务信息接口
const infoResult = await chatApi.getServiceInfo()
debugLog('AI服务信息:', infoResult)
return {
success: true,
message: 'AI服务连接正常',
data: {
health: healthResult,
info: infoResult
}
}
} catch (error) {
debugLog('AI服务测试失败:', error)
return {
success: false,
message: 'AI服务连接失败',
error: error.message
}
}
},
// 测试所有服务
async testAllServices() {
debugLog('开始测试所有服务...')
const results = {
user: await this.testUserService(),
ai: await this.testAiService()
}
const allSuccess = Object.values(results).every(result => result.success)
debugLog('所有服务测试结果:', results)
return {
success: allSuccess,
message: allSuccess ? '所有服务连接正常' : '部分服务连接失败',
results
}
},
// 测试用户注册流程
async testUserRegister() {
debugLog('开始测试用户注册流程...')
const testUser = {
account: `test_${Date.now()}`,
password: 'Test123456',
email: `test_${Date.now()}@example.com`,
phone: `138${Date.now().toString().slice(-8)}`,
nickname: '测试用户'
}
try {
const result = await userApi.register(testUser)
debugLog('用户注册测试成功:', result)
return {
success: true,
message: '用户注册流程正常',
data: result
}
} catch (error) {
debugLog('用户注册测试失败:', error)
return {
success: false,
message: '用户注册流程失败',
error: error.message
}
}
},
// 测试AI对话流程
async testAiChat() {
debugLog('开始测试AI对话流程...')
try {
// 1. 创建会话
const conversationData = {
userId: 'test_user',
title: '测试会话',
type: 'chat'
}
const createResult = await chatApi.createConversation(conversationData)
debugLog('创建会话测试:', createResult)
if (!createResult.success) {
throw new Error('创建会话失败')
}
// 2. 发送消息
const messageData = {
userId: 'test_user',
conversationId: createResult.data.conversationId,
message: '你好,这是一条测试消息'
}
const chatResult = await chatApi.sendMessage(messageData)
debugLog('发送消息测试:', chatResult)
return {
success: true,
message: 'AI对话流程正常',
data: {
conversation: createResult.data,
chat: chatResult.data
}
}
} catch (error) {
debugLog('AI对话测试失败:', error)
return {
success: false,
message: 'AI对话流程失败',
error: error.message
}
}
},
// 测试情绪分析
async testEmotionAnalysis() {
debugLog('开始测试情绪分析...')
try {
const analysisData = {
userId: 'test_user',
text: '我今天心情很好,阳光明媚,感觉充满了希望和活力。'
}
const result = await chatApi.analyzeEmotion(analysisData)
debugLog('情绪分析测试:', result)
return {
success: true,
message: '情绪分析功能正常',
data: result.data
}
} catch (error) {
debugLog('情绪分析测试失败:', error)
return {
success: false,
message: '情绪分析功能失败',
error: error.message
}
}
},
// 测试访客聊天功能
async testGuestChat() {
debugLog('开始测试访客聊天功能...')
try {
// 1. 获取访客用户信息
const userInfoResult = await guestChatApi.getGuestUserInfo()
debugLog('获取访客用户信息:', userInfoResult)
if (!userInfoResult.success) {
throw new Error('获取访客用户信息失败')
}
// 2. 发送访客聊天消息
const chatData = {
message: '你好,我是访客用户,这是一条测试消息。',
title: '访客测试会话'
}
const chatResult = await guestChatApi.guestChat(chatData)
debugLog('访客聊天测试:', chatResult)
if (!chatResult.success) {
throw new Error('访客聊天失败')
}
// 3. 获取访客会话列表
const conversationsResult = await guestChatApi.getGuestConversations()
debugLog('访客会话列表:', conversationsResult)
return {
success: true,
message: '访客聊天功能正常',
data: {
userInfo: userInfoResult.data,
chat: chatResult.data,
conversations: conversationsResult.data
}
}
} catch (error) {
debugLog('访客聊天测试失败:', error)
return {
success: false,
message: '访客聊天功能失败',
error: error.message
}
}
},
// 测试访客情绪分析
async testGuestEmotionAnalysis() {
debugLog('开始测试访客情绪分析...')
try {
const analysisData = {
text: '我感到有些焦虑和不安,不知道该怎么办。'
}
const result = await guestChatApi.analyzeGuestEmotion(analysisData)
debugLog('访客情绪分析测试:', result)
return {
success: true,
message: '访客情绪分析功能正常',
data: result.data
}
} catch (error) {
debugLog('访客情绪分析测试失败:', error)
return {
success: false,
message: '访客情绪分析功能失败',
error: error.message
}
}
},
// 测试访客服务健康检查
async testGuestHealthCheck() {
debugLog('开始测试访客服务健康检查...')
try {
const result = await guestChatApi.guestHealthCheck()
debugLog('访客服务健康检查:', result)
return {
success: true,
message: '访客服务健康检查正常',
data: result.data
}
} catch (error) {
debugLog('访客服务健康检查失败:', error)
return {
success: false,
message: '访客服务健康检查失败',
error: error.message
}
}
}
}
// 导出单个测试函数,方便在控制台调用
export const {
testUserService,
testAiService,
testAllServices,
testUserRegister,
testAiChat,
testEmotionAnalysis,
testGuestChat,
testGuestEmotionAnalysis,
testGuestHealthCheck
} = apiTest
+65
View File
@@ -0,0 +1,65 @@
import request from './request'
/**
* 用户相关API
* 对应后端 UserController (/user)
*/
export const userApi = {
// 用户注册
register(data) {
return request.post('/user/register', data)
},
// 用户登录
login(data) {
return request.post('/user/login', data)
},
// 刷新Token
refreshToken(refreshToken) {
return request.post('/user/refresh', null, {
params: { refreshToken }
})
},
// 获取用户信息
getUserInfo(userId) {
return request.get(`/user/info/${userId}`)
},
// 更新用户信息
updateUserInfo(userId, data) {
return request.put(`/user/info/${userId}`, data)
},
// 检查账号是否存在
checkAccount(account) {
return request.get('/user/check/account', {
params: { account }
})
},
// 检查邮箱是否存在
checkEmail(email) {
return request.get('/user/check/email', {
params: { email }
})
},
// 检查手机号是否存在
checkPhone(phone) {
return request.get('/user/check/phone', {
params: { phone }
})
},
// 更新最后活跃时间
updateLastActiveTime(userId) {
return request.post(`/user/active/${userId}`)
},
// 用户登出
logout(userId) {
return request.post(`/user/logout/${userId}`)
}
}
+375
View File
@@ -0,0 +1,375 @@
<template>
<div class="api-test">
<a-card title="API接口测试" size="small">
<div class="test-buttons">
<a-space wrap>
<a-button type="primary" @click="testAllServices" :loading="loading.all">
测试所有服务
</a-button>
<a-button @click="testUserService" :loading="loading.user">
测试用户服务
</a-button>
<a-button @click="testAiService" :loading="loading.ai">
测试AI服务
</a-button>
<a-button @click="testUserRegister" :loading="loading.register">
测试用户注册
</a-button>
<a-button @click="testAiChat" :loading="loading.chat">
测试AI对话
</a-button>
<a-button @click="testEmotionAnalysis" :loading="loading.emotion">
测试情绪分析
</a-button>
<a-button @click="testGuestChat" :loading="loading.guestChat">
测试访客聊天
</a-button>
<a-button @click="testGuestEmotion" :loading="loading.guestEmotion">
测试访客情绪分析
</a-button>
<a-button @click="testGuestHealth" :loading="loading.guestHealth">
测试访客服务
</a-button>
<a-button @click="clearResults" type="dashed">
清空结果
</a-button>
</a-space>
</div>
<div v-if="results.length > 0" class="test-results">
<a-divider>测试结果</a-divider>
<div v-for="(result, index) in results" :key="index" class="result-item">
<a-alert
:type="result.success ? 'success' : 'error'"
:message="result.message"
:description="result.description"
show-icon
closable
@close="removeResult(index)"
>
<template #description>
<div class="result-details">
<div v-if="result.data" class="result-data">
<strong>响应数据:</strong>
<pre>{{ JSON.stringify(result.data, null, 2) }}</pre>
</div>
<div v-if="result.error" class="result-error">
<strong>错误信息:</strong>
<code>{{ result.error }}</code>
</div>
<div class="result-time">
<small>测试时间: {{ result.timestamp }}</small>
</div>
</div>
</template>
</a-alert>
</div>
</div>
</a-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import { apiTest } from '@/api/test'
import { ENV_CONFIG } from '@/config/env'
// 加载状态
const loading = reactive({
all: false,
user: false,
ai: false,
register: false,
chat: false,
emotion: false,
guestChat: false,
guestEmotion: false,
guestHealth: false
})
// 测试结果
const results = ref([])
// 添加测试结果
const addResult = (result) => {
results.value.unshift({
...result,
timestamp: new Date().toLocaleString()
})
}
// 移除测试结果
const removeResult = (index) => {
results.value.splice(index, 1)
}
// 清空结果
const clearResults = () => {
results.value = []
message.success('已清空测试结果')
}
// 测试所有服务
const testAllServices = async () => {
loading.all = true
try {
const result = await apiTest.testAllServices()
addResult({
...result,
description: `环境: ${ENV_CONFIG.APP_ENV}, API地址: ${ENV_CONFIG.API_BASE_URL}`
})
if (result.success) {
message.success('所有服务测试完成')
} else {
message.warning('部分服务测试失败')
}
} catch (error) {
addResult({
success: false,
message: '测试执行失败',
error: error.message
})
message.error('测试执行失败')
} finally {
loading.all = false
}
}
// 测试用户服务
const testUserService = async () => {
loading.user = true
try {
const result = await apiTest.testUserService()
addResult(result)
if (result.success) {
message.success('用户服务测试成功')
} else {
message.error('用户服务测试失败')
}
} catch (error) {
addResult({
success: false,
message: '用户服务测试失败',
error: error.message
})
message.error('用户服务测试失败')
} finally {
loading.user = false
}
}
// 测试AI服务
const testAiService = async () => {
loading.ai = true
try {
const result = await apiTest.testAiService()
addResult(result)
if (result.success) {
message.success('AI服务测试成功')
} else {
message.error('AI服务测试失败')
}
} catch (error) {
addResult({
success: false,
message: 'AI服务测试失败',
error: error.message
})
message.error('AI服务测试失败')
} finally {
loading.ai = false
}
}
// 测试用户注册
const testUserRegister = async () => {
loading.register = true
try {
const result = await apiTest.testUserRegister()
addResult(result)
if (result.success) {
message.success('用户注册测试成功')
} else {
message.error('用户注册测试失败')
}
} catch (error) {
addResult({
success: false,
message: '用户注册测试失败',
error: error.message
})
message.error('用户注册测试失败')
} finally {
loading.register = false
}
}
// 测试AI对话
const testAiChat = async () => {
loading.chat = true
try {
const result = await apiTest.testAiChat()
addResult(result)
if (result.success) {
message.success('AI对话测试成功')
} else {
message.error('AI对话测试失败')
}
} catch (error) {
addResult({
success: false,
message: 'AI对话测试失败',
error: error.message
})
message.error('AI对话测试失败')
} finally {
loading.chat = false
}
}
// 测试情绪分析
const testEmotionAnalysis = async () => {
loading.emotion = true
try {
const result = await apiTest.testEmotionAnalysis()
addResult(result)
if (result.success) {
message.success('情绪分析测试成功')
} else {
message.error('情绪分析测试失败')
}
} catch (error) {
addResult({
success: false,
message: '情绪分析测试失败',
error: error.message
})
message.error('情绪分析测试失败')
} finally {
loading.emotion = false
}
}
// 测试访客聊天
const testGuestChat = async () => {
loading.guestChat = true
try {
const result = await apiTest.testGuestChat()
addResult(result)
if (result.success) {
message.success('访客聊天测试成功')
} else {
message.error('访客聊天测试失败')
}
} catch (error) {
addResult({
success: false,
message: '访客聊天测试失败',
error: error.message
})
message.error('访客聊天测试失败')
} finally {
loading.guestChat = false
}
}
// 测试访客情绪分析
const testGuestEmotion = async () => {
loading.guestEmotion = true
try {
const result = await apiTest.testGuestEmotionAnalysis()
addResult(result)
if (result.success) {
message.success('访客情绪分析测试成功')
} else {
message.error('访客情绪分析测试失败')
}
} catch (error) {
addResult({
success: false,
message: '访客情绪分析测试失败',
error: error.message
})
message.error('访客情绪分析测试失败')
} finally {
loading.guestEmotion = false
}
}
// 测试访客服务健康检查
const testGuestHealth = async () => {
loading.guestHealth = true
try {
const result = await apiTest.testGuestHealthCheck()
addResult(result)
if (result.success) {
message.success('访客服务健康检查成功')
} else {
message.error('访客服务健康检查失败')
}
} catch (error) {
addResult({
success: false,
message: '访客服务健康检查失败',
error: error.message
})
message.error('访客服务健康检查失败')
} finally {
loading.guestHealth = false
}
}
</script>
<style scoped>
.api-test {
margin: 16px;
}
.test-buttons {
margin-bottom: 16px;
}
.test-results {
max-height: 600px;
overflow-y: auto;
}
.result-item {
margin-bottom: 12px;
}
.result-details {
margin-top: 8px;
}
.result-data pre {
background: #f5f5f5;
padding: 8px;
border-radius: 4px;
font-size: 12px;
max-height: 200px;
overflow-y: auto;
}
.result-error code {
background: #fff2f0;
color: #ff4d4f;
padding: 2px 4px;
border-radius: 3px;
}
.result-time {
margin-top: 8px;
color: #666;
}
</style>
+178
View File
@@ -0,0 +1,178 @@
<template>
<div class="captcha-container">
<div class="captcha-input-group">
<a-input
v-model:value="captchaValue"
:placeholder="placeholder"
:size="size"
@input="handleInput"
@pressEnter="handleEnter"
class="captcha-input"
>
<template #suffix>
<div class="captcha-image-wrapper" @click="refreshCaptcha">
<img
v-if="captchaImage"
:src="captchaImage"
alt="验证码"
class="captcha-image"
/>
<div v-else class="captcha-loading">
<a-spin size="small" />
</div>
</div>
</template>
</a-input>
</div>
<a-button
type="link"
size="small"
@click="refreshCaptcha"
class="refresh-btn"
>
<ReloadOutlined />
刷新
</a-button>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ReloadOutlined } from '@ant-design/icons-vue'
import { captchaApi } from '@/api/captcha'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
captchaId: {
type: String,
default: ''
},
type: {
type: String,
default: 'arithmetic'
},
placeholder: {
type: String,
default: '请输入验证码'
},
size: {
type: String,
default: 'large'
}
})
const emit = defineEmits(['update:modelValue', 'update:captchaId', 'enter'])
const captchaValue = ref('')
const captchaImage = ref('')
const currentCaptchaId = ref('')
// 监听外部值变化
watch(() => props.modelValue, (newVal) => {
captchaValue.value = newVal
})
// 监听输入变化
const handleInput = (value) => {
emit('update:modelValue', value)
}
// 处理回车
const handleEnter = () => {
emit('enter')
}
// 生成验证码
const generateCaptcha = async () => {
try {
const response = await captchaApi.generate(props.type)
if (response.success) {
captchaImage.value = response.data.captchaImage
currentCaptchaId.value = response.data.captchaId
emit('update:captchaId', response.data.captchaId)
}
} catch (error) {
console.error('生成验证码失败:', error)
}
}
// 刷新验证码
const refreshCaptcha = () => {
captchaValue.value = ''
emit('update:modelValue', '')
generateCaptcha()
}
// 组件挂载时生成验证码
onMounted(() => {
generateCaptcha()
})
// 暴露方法给父组件
defineExpose({
refresh: refreshCaptcha
})
</script>
<style lang="scss" scoped>
.captcha-container {
display: flex;
align-items: center;
gap: 8px;
}
.captcha-input-group {
flex: 1;
.captcha-input {
:deep(.ant-input) {
padding-right: 90px;
}
}
}
.captcha-image-wrapper {
width: 80px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
transition: all 0.3s;
&:hover {
border-color: #40a9ff;
}
}
.captcha-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 3px;
}
.captcha-loading {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.refresh-btn {
padding: 0;
height: auto;
color: #666;
&:hover {
color: #1890ff;
}
}
</style>
+357
View File
@@ -0,0 +1,357 @@
<template>
<div class="conversation-detail">
<!-- 对话信息 -->
<div class="conversation-info">
<div class="info-row">
<span class="info-label">创建时间:</span>
<span class="info-value">{{ formatTime(conversation.createTime) }}</span>
</div>
<div class="info-row">
<span class="info-label">最后更新:</span>
<span class="info-value">{{ formatTime(conversation.updateTime) }}</span>
</div>
<div class="info-row">
<span class="info-label">消息数量:</span>
<span class="info-value">{{ conversation.messageCount || 0 }} </span>
</div>
<div class="info-row">
<span class="info-label">对话状态:</span>
<a-tag :color="getStatusColor(conversation.status)">
{{ getStatusText(conversation.status) }}
</a-tag>
</div>
</div>
<!-- 消息列表 -->
<div class="messages-section">
<div class="section-title">
<MessageOutlined />
对话内容
</div>
<a-spin :spinning="loading">
<div class="messages-list" v-if="messages.length > 0">
<div
class="message-item"
:class="message.sender"
v-for="message in messages"
:key="message.id"
>
<div class="message-header">
<div class="message-sender">
<UserOutlined v-if="message.sender === 'user'" />
<RobotOutlined v-else />
<span>{{ message.sender === 'user' ? '用户' : 'AI助手' }}</span>
</div>
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
</div>
<div class="message-content">
<div class="message-text" v-html="formatMessage(message.content)"></div>
<!-- 情绪分析 -->
<div class="emotion-section" v-if="message.emotionAnalysis">
<EmotionAnalysis :analysis="message.emotionAnalysis" />
</div>
</div>
</div>
</div>
<div class="empty-messages" v-else>
<CommentOutlined class="empty-icon" />
<p>暂无消息记录</p>
</div>
</a-spin>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<a-button
type="primary"
class="gradient-btn"
@click="$emit('continue')"
v-if="conversation.status === 'active'"
>
<PlayCircleOutlined />
继续对话
</a-button>
<a-button @click="exportConversation">
<DownloadOutlined />
导出对话
</a-button>
<a-button @click="shareConversation">
<ShareAltOutlined />
分享对话
</a-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
MessageOutlined,
UserOutlined,
RobotOutlined,
PlayCircleOutlined,
DownloadOutlined,
ShareAltOutlined,
CommentOutlined
} from '@ant-design/icons-vue'
import { chatApi } from '@/api/chat'
import { formatTime, formatMessage } from '@/utils/format'
import EmotionAnalysis from './EmotionAnalysis.vue'
const props = defineProps({
conversation: {
type: Object,
required: true
}
})
const emit = defineEmits(['continue'])
// 响应式数据
const loading = ref(false)
const messages = ref([])
// 方法
const getStatusColor = (status) => {
const colorMap = {
active: 'success',
ended: 'default',
archived: 'warning'
}
return colorMap[status] || 'default'
}
const getStatusText = (status) => {
const textMap = {
active: '进行中',
ended: '已结束',
archived: '已归档'
}
return textMap[status] || status
}
const fetchMessages = async () => {
try {
loading.value = true
const response = await chatApi.getMessages(props.conversation.conversationId)
if (response.success) {
messages.value = response.data || []
}
} catch (error) {
console.error('获取消息失败:', error)
message.error('获取消息失败')
} finally {
loading.value = false
}
}
const exportConversation = () => {
try {
// 生成导出内容
const exportContent = generateExportContent()
// 创建下载链接
const blob = new Blob([exportContent], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${props.conversation.title}_${formatTime(props.conversation.createTime, 'YYYY-MM-DD')}.txt`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
message.success('对话已导出')
} catch (error) {
console.error('导出对话失败:', error)
message.error('导出失败')
}
}
const generateExportContent = () => {
let content = `对话标题: ${props.conversation.title}\n`
content += `创建时间: ${formatTime(props.conversation.createTime)}\n`
content += `更新时间: ${formatTime(props.conversation.updateTime)}\n`
content += `消息数量: ${props.conversation.messageCount || 0}\n`
content += `对话状态: ${getStatusText(props.conversation.status)}\n`
content += '\n' + '='.repeat(50) + '\n\n'
messages.value.forEach(msg => {
const sender = msg.sender === 'user' ? '用户' : 'AI助手'
content += `[${formatTime(msg.timestamp)}] ${sender}:\n`
content += `${msg.content}\n\n`
if (msg.emotionAnalysis) {
content += `情绪分析: ${msg.emotionAnalysis.primaryEmotion || '未知'}\n\n`
}
})
return content
}
const shareConversation = async () => {
try {
// 生成分享链接或内容
const shareText = `我在情绪博物馆进行了一次有意义的AI对话:${props.conversation.title}`
if (navigator.share) {
await navigator.share({
title: '情绪博物馆 - AI对话分享',
text: shareText,
url: window.location.href
})
} else {
// 复制到剪贴板
await navigator.clipboard.writeText(shareText)
message.success('分享内容已复制到剪贴板')
}
} catch (error) {
console.error('分享失败:', error)
message.error('分享失败')
}
}
// 组件挂载
onMounted(() => {
fetchMessages()
})
</script>
<style lang="scss" scoped>
.conversation-detail {
.conversation-info {
background: var(--bg-secondary);
padding: var(--spacing-md);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-lg);
.info-row {
display: flex;
align-items: center;
margin-bottom: var(--spacing-sm);
&:last-child {
margin-bottom: 0;
}
.info-label {
font-weight: 500;
color: var(--text-secondary);
min-width: 80px;
}
.info-value {
color: var(--text-primary);
}
}
}
.messages-section {
margin-bottom: var(--spacing-lg);
.section-title {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--border-color);
}
.messages-list {
max-height: 400px;
overflow-y: auto;
.message-item {
margin-bottom: var(--spacing-lg);
&:last-child {
margin-bottom: 0;
}
.message-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-sm);
.message-sender {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-weight: 500;
color: var(--text-primary);
.anticon {
font-size: 16px;
}
}
.message-time {
font-size: 12px;
color: var(--text-secondary);
}
}
.message-content {
.message-text {
padding: var(--spacing-md);
border-radius: var(--border-radius);
line-height: 1.6;
word-wrap: break-word;
}
.emotion-section {
margin-top: var(--spacing-sm);
}
}
&.user {
.message-text {
background: var(--gradient-primary);
color: white;
margin-left: var(--spacing-xl);
}
}
&.assistant {
.message-text {
background: var(--bg-primary);
border: 1px solid var(--border-color);
margin-right: var(--spacing-xl);
}
}
}
}
.empty-messages {
text-align: center;
padding: var(--spacing-xxl);
color: var(--text-secondary);
.empty-icon {
font-size: 48px;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
}
}
.action-buttons {
display: flex;
gap: var(--spacing-md);
justify-content: center;
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border-color);
}
}
</style>
+381
View File
@@ -0,0 +1,381 @@
<template>
<div class="emotion-analysis">
<a-card size="small" class="analysis-card">
<template #title>
<div class="card-title">
<HeartOutlined class="title-icon" />
情绪分析
</div>
</template>
<div class="analysis-content">
<!-- 主要情绪 -->
<div class="primary-emotion" v-if="analysis.primaryEmotion">
<div class="emotion-label">主要情绪</div>
<a-tag
:color="getEmotionColor(analysis.primaryEmotion)"
class="emotion-tag"
>
{{ getEmotionText(analysis.primaryEmotion) }}
</a-tag>
<div class="emotion-intensity" v-if="analysis.intensity">
强度: {{ Math.round(analysis.intensity * 100) }}%
</div>
</div>
<!-- 情绪极性 -->
<div class="emotion-polarity" v-if="analysis.polarity">
<div class="polarity-label">情绪倾向</div>
<a-tag
:color="getPolarityColor(analysis.polarity)"
class="polarity-tag"
>
{{ getPolarityText(analysis.polarity) }}
</a-tag>
</div>
<!-- 情绪分布 -->
<div class="emotions-distribution" v-if="analysis.emotions && Object.keys(analysis.emotions).length > 0">
<div class="distribution-label">情绪分布</div>
<div class="emotion-bars">
<div
class="emotion-bar"
v-for="(value, emotion) in analysis.emotions"
:key="emotion"
>
<div class="bar-label">{{ getEmotionText(emotion) }}</div>
<div class="bar-container">
<div
class="bar-fill"
:style="{
width: `${value * 100}%`,
background: getEmotionGradient(emotion)
}"
></div>
</div>
<div class="bar-value">{{ Math.round(value * 100) }}%</div>
</div>
</div>
</div>
<!-- 关键词 -->
<div class="keywords" v-if="analysis.keywords && analysis.keywords.length > 0">
<div class="keywords-label">关键词</div>
<div class="keywords-list">
<a-tag
v-for="keyword in analysis.keywords"
:key="keyword"
class="keyword-tag"
>
{{ keyword }}
</a-tag>
</div>
</div>
<!-- 建议 -->
<div class="suggestion" v-if="analysis.suggestion">
<div class="suggestion-label">
<BulbOutlined class="suggestion-icon" />
建议
</div>
<div class="suggestion-content">{{ analysis.suggestion }}</div>
</div>
<!-- 置信度 -->
<div class="confidence" v-if="analysis.confidence">
<div class="confidence-label">分析置信度</div>
<a-progress
:percent="Math.round(analysis.confidence * 100)"
:stroke-color="getConfidenceColor(analysis.confidence)"
size="small"
:show-info="true"
/>
</div>
</div>
</a-card>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { HeartOutlined, BulbOutlined } from '@ant-design/icons-vue'
const props = defineProps({
analysis: {
type: Object,
required: true,
default: () => ({})
}
})
// 情绪映射
const emotionMap = {
joy: '喜悦',
sadness: '悲伤',
anger: '愤怒',
fear: '恐惧',
surprise: '惊讶',
disgust: '厌恶',
trust: '信任',
anticipation: '期待',
anxiety: '焦虑',
depression: '抑郁',
excitement: '兴奋',
calm: '平静',
stress: '压力',
happiness: '快乐',
worry: '担忧',
relief: '放松',
frustration: '沮丧',
hope: '希望',
love: '爱',
hate: '恨'
}
// 极性映射
const polarityMap = {
positive: '积极',
negative: '消极',
neutral: '中性'
}
// 获取情绪文本
const getEmotionText = (emotion) => {
return emotionMap[emotion] || emotion
}
// 获取极性文本
const getPolarityText = (polarity) => {
return polarityMap[polarity] || polarity
}
// 获取情绪颜色
const getEmotionColor = (emotion) => {
const colorMap = {
joy: 'gold',
happiness: 'gold',
excitement: 'orange',
love: 'magenta',
trust: 'blue',
hope: 'cyan',
calm: 'green',
relief: 'green',
sadness: 'blue',
depression: 'purple',
worry: 'orange',
anxiety: 'orange',
stress: 'red',
anger: 'red',
frustration: 'red',
hate: 'red',
fear: 'volcano',
surprise: 'lime',
anticipation: 'geekblue',
disgust: 'default'
}
return colorMap[emotion] || 'default'
}
// 获取极性颜色
const getPolarityColor = (polarity) => {
const colorMap = {
positive: 'success',
negative: 'error',
neutral: 'default'
}
return colorMap[polarity] || 'default'
}
// 获取情绪渐变色
const getEmotionGradient = (emotion) => {
const gradientMap = {
joy: 'linear-gradient(90deg, #ffd700, #ffed4e)',
happiness: 'linear-gradient(90deg, #ff9a9e, #fecfef)',
excitement: 'linear-gradient(90deg, #ff6b6b, #ffa726)',
love: 'linear-gradient(90deg, #ff6b9d, #c44569)',
trust: 'linear-gradient(90deg, #4facfe, #00f2fe)',
hope: 'linear-gradient(90deg, #43e97b, #38f9d7)',
calm: 'linear-gradient(90deg, #667eea, #764ba2)',
relief: 'linear-gradient(90deg, #a8edea, #fed6e3)',
sadness: 'linear-gradient(90deg, #74b9ff, #0984e3)',
depression: 'linear-gradient(90deg, #6c5ce7, #a29bfe)',
worry: 'linear-gradient(90deg, #fdcb6e, #e17055)',
anxiety: 'linear-gradient(90deg, #fd79a8, #fdcb6e)',
stress: 'linear-gradient(90deg, #e84393, #fd79a8)',
anger: 'linear-gradient(90deg, #d63031, #e84393)',
frustration: 'linear-gradient(90deg, #e17055, #d63031)',
hate: 'linear-gradient(90deg, #2d3436, #636e72)',
fear: 'linear-gradient(90deg, #fd79a8, #fdcb6e)',
surprise: 'linear-gradient(90deg, #00b894, #00cec9)',
anticipation: 'linear-gradient(90deg, #74b9ff, #0984e3)',
disgust: 'linear-gradient(90deg, #636e72, #2d3436)'
}
return gradientMap[emotion] || 'linear-gradient(90deg, #ddd, #bbb)'
}
// 获取置信度颜色
const getConfidenceColor = (confidence) => {
if (confidence >= 0.8) return '#52c41a'
if (confidence >= 0.6) return '#faad14'
return '#ff4d4f'
}
</script>
<style lang="scss" scoped>
.emotion-analysis {
.analysis-card {
border-radius: var(--border-radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
:deep(.ant-card-head) {
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-sm) var(--spacing-md);
.ant-card-head-title {
padding: 0;
}
}
:deep(.ant-card-body) {
padding: var(--spacing-md);
}
}
.card-title {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 14px;
font-weight: 600;
color: var(--primary-color);
.title-icon {
font-size: 16px;
}
}
.analysis-content {
.primary-emotion,
.emotion-polarity,
.emotions-distribution,
.keywords,
.suggestion,
.confidence {
margin-bottom: var(--spacing-md);
&:last-child {
margin-bottom: 0;
}
}
.emotion-label,
.polarity-label,
.distribution-label,
.keywords-label,
.suggestion-label,
.confidence-label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
font-weight: 500;
}
.emotion-tag,
.polarity-tag {
font-size: 12px;
border-radius: var(--border-radius-small);
margin-right: var(--spacing-xs);
}
.emotion-intensity {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
}
.emotion-bars {
.emotion-bar {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
&:last-child {
margin-bottom: 0;
}
.bar-label {
font-size: 11px;
color: var(--text-secondary);
min-width: 40px;
text-align: right;
}
.bar-container {
flex: 1;
height: 8px;
background: var(--bg-secondary);
border-radius: 4px;
overflow: hidden;
.bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
}
.bar-value {
font-size: 11px;
color: var(--text-secondary);
min-width: 30px;
text-align: right;
}
}
}
.keywords-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
.keyword-tag {
font-size: 11px;
border-radius: var(--border-radius-small);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
}
.suggestion-label {
display: flex;
align-items: center;
gap: var(--spacing-xs);
.suggestion-icon {
font-size: 12px;
color: var(--primary-color);
}
}
.suggestion-content {
font-size: 12px;
color: var(--text-primary);
line-height: 1.5;
background: var(--bg-secondary);
padding: var(--spacing-sm);
border-radius: var(--border-radius-small);
border-left: 3px solid var(--primary-color);
}
.confidence {
:deep(.ant-progress) {
.ant-progress-text {
font-size: 11px;
}
}
}
}
}
</style>
@@ -0,0 +1,296 @@
<template>
<div class="emotion-analysis-simple">
<a-card size="small" class="analysis-card">
<template #title>
<div class="card-title">
<HeartOutlined class="title-icon" />
情绪分析
</div>
</template>
<div class="analysis-content">
<!-- 主要情绪 -->
<div class="primary-emotion" v-if="analysis.primaryEmotion">
<span class="emotion-label">主要情绪:</span>
<a-tag
:color="getEmotionColor(analysis.primaryEmotion)"
class="emotion-tag"
>
{{ getEmotionText(analysis.primaryEmotion) }}
</a-tag>
<span class="emotion-intensity" v-if="analysis.intensity">
({{ Math.round(analysis.intensity * 100) }}%)
</span>
</div>
<!-- 情绪极性 -->
<div class="emotion-polarity" v-if="analysis.polarity">
<span class="polarity-label">情绪倾向:</span>
<a-tag
:color="getPolarityColor(analysis.polarity)"
class="polarity-tag"
>
{{ getPolarityText(analysis.polarity) }}
</a-tag>
</div>
<!-- 关键词 -->
<div class="keywords" v-if="analysis.keywords && analysis.keywords.length > 0">
<span class="keywords-label">关键词:</span>
<div class="keywords-list">
<a-tag
v-for="keyword in analysis.keywords.slice(0, 3)"
:key="keyword"
class="keyword-tag"
size="small"
>
{{ keyword }}
</a-tag>
</div>
</div>
<!-- 建议 -->
<div class="suggestion" v-if="analysis.suggestion">
<div class="suggestion-label">
<BulbOutlined class="suggestion-icon" />
建议:
</div>
<div class="suggestion-content">{{ analysis.suggestion }}</div>
</div>
<!-- 置信度 -->
<div class="confidence" v-if="analysis.confidence">
<span class="confidence-label">置信度:</span>
<a-progress
:percent="Math.round(analysis.confidence * 100)"
:stroke-color="getConfidenceColor(analysis.confidence)"
size="small"
:show-info="false"
style="width: 80px; display: inline-block; margin-left: 8px;"
/>
<span class="confidence-value">{{ Math.round(analysis.confidence * 100) }}%</span>
</div>
</div>
</a-card>
</div>
</template>
<script setup>
import { HeartOutlined, BulbOutlined } from '@ant-design/icons-vue'
const props = defineProps({
analysis: {
type: Object,
required: true,
default: () => ({})
}
})
// 情绪映射
const emotionMap = {
joy: '喜悦',
sadness: '悲伤',
anger: '愤怒',
fear: '恐惧',
surprise: '惊讶',
disgust: '厌恶',
trust: '信任',
anticipation: '期待',
anxiety: '焦虑',
depression: '抑郁',
excitement: '兴奋',
calm: '平静',
stress: '压力',
happiness: '快乐',
worry: '担忧',
relief: '放松',
frustration: '沮丧',
hope: '希望',
love: '爱',
hate: '恨'
}
// 极性映射
const polarityMap = {
positive: '积极',
negative: '消极',
neutral: '中性'
}
// 获取情绪文本
const getEmotionText = (emotion) => {
return emotionMap[emotion] || emotion
}
// 获取极性文本
const getPolarityText = (polarity) => {
return polarityMap[polarity] || polarity
}
// 获取情绪颜色
const getEmotionColor = (emotion) => {
const colorMap = {
joy: 'gold',
happiness: 'gold',
excitement: 'orange',
love: 'magenta',
trust: 'blue',
hope: 'cyan',
calm: 'green',
relief: 'green',
sadness: 'blue',
depression: 'purple',
worry: 'orange',
anxiety: 'orange',
stress: 'red',
anger: 'red',
frustration: 'red',
hate: 'red',
fear: 'volcano',
surprise: 'lime',
anticipation: 'geekblue',
disgust: 'default'
}
return colorMap[emotion] || 'default'
}
// 获取极性颜色
const getPolarityColor = (polarity) => {
const colorMap = {
positive: 'success',
negative: 'error',
neutral: 'default'
}
return colorMap[polarity] || 'default'
}
// 获取置信度颜色
const getConfidenceColor = (confidence) => {
if (confidence >= 0.8) return '#52c41a'
if (confidence >= 0.6) return '#faad14'
return '#ff4d4f'
}
</script>
<style lang="scss" scoped>
.emotion-analysis-simple {
.analysis-card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-top: 8px;
:deep(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
padding: 8px 12px;
min-height: auto;
.ant-card-head-title {
padding: 0;
font-size: 13px;
}
}
:deep(.ant-card-body) {
padding: 12px;
}
}
.card-title {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 600;
color: #667eea;
.title-icon {
font-size: 14px;
}
}
.analysis-content {
.primary-emotion,
.emotion-polarity,
.keywords,
.suggestion,
.confidence {
margin-bottom: 8px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
&:last-child {
margin-bottom: 0;
}
}
.emotion-label,
.polarity-label,
.keywords-label,
.confidence-label {
font-size: 12px;
color: #666;
font-weight: 500;
min-width: fit-content;
}
.emotion-tag,
.polarity-tag {
font-size: 11px;
border-radius: 4px;
}
.emotion-intensity,
.confidence-value {
font-size: 11px;
color: #999;
}
.keywords-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
.keyword-tag {
font-size: 10px;
border-radius: 3px;
background: #f5f5f5;
border: 1px solid #d9d9d9;
color: #666;
}
}
.suggestion {
flex-direction: column;
align-items: flex-start;
.suggestion-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #666;
font-weight: 500;
.suggestion-icon {
font-size: 12px;
color: #667eea;
}
}
.suggestion-content {
font-size: 11px;
color: #333;
line-height: 1.4;
background: #f8f9fa;
padding: 6px 8px;
border-radius: 4px;
border-left: 2px solid #667eea;
margin-top: 4px;
width: 100%;
}
}
}
}
</style>
+665
View File
@@ -0,0 +1,665 @@
<template>
<div class="emotion-trends">
<div class="trends-header">
<h3 class="trends-title">
<LineChartOutlined />
情绪趋势分析
</h3>
<div class="trends-controls">
<a-select
v-model:value="selectedTimeRange"
style="width: 120px"
@change="updateChart"
>
<a-select-option value="7">近7天</a-select-option>
<a-select-option value="30">近30天</a-select-option>
<a-select-option value="90">近90天</a-select-option>
</a-select>
</div>
</div>
<div class="chart-container" ref="chartContainer">
<!-- 这里可以集成图表库如 ECharts Chart.js -->
<div class="simple-chart">
<!-- 情绪分布饼图 -->
<div class="emotion-distribution">
<h4 class="chart-title">情绪分布</h4>
<div class="pie-chart">
<div
class="pie-slice"
v-for="(emotion, index) in emotionDistribution"
:key="emotion.name"
:style="getPieSliceStyle(emotion, index)"
>
<div class="slice-label">
{{ emotion.name }}
<span class="slice-percentage">{{ emotion.percentage }}%</span>
</div>
</div>
</div>
</div>
<!-- 情绪强度趋势 -->
<div class="intensity-trend">
<h4 class="chart-title">情绪强度趋势</h4>
<div class="line-chart">
<div class="chart-grid">
<div class="grid-line" v-for="i in 5" :key="i"></div>
</div>
<div class="trend-line">
<div
class="data-point"
v-for="(point, index) in intensityTrend"
:key="index"
:style="getDataPointStyle(point, index)"
@mouseenter="showTooltip(point, $event)"
@mouseleave="hideTooltip"
>
<div class="point-marker"></div>
</div>
</div>
</div>
</div>
<!-- 情绪极性统计 -->
<div class="polarity-stats">
<h4 class="chart-title">情绪极性统计</h4>
<div class="bar-chart">
<div
class="bar-item"
v-for="polarity in polarityStats"
:key="polarity.name"
>
<div class="bar-label">{{ polarity.name }}</div>
<div class="bar-container">
<div
class="bar-fill"
:style="{
width: `${polarity.percentage}%`,
background: polarity.color
}"
></div>
</div>
<div class="bar-value">{{ polarity.count }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 数据洞察 -->
<div class="insights-section">
<h4 class="insights-title">
<BulbOutlined />
数据洞察
</h4>
<div class="insights-list">
<div
class="insight-item"
v-for="insight in insights"
:key="insight.id"
>
<div class="insight-icon">
<component :is="insight.icon" />
</div>
<div class="insight-content">
<div class="insight-text">{{ insight.text }}</div>
<div class="insight-suggestion" v-if="insight.suggestion">
{{ insight.suggestion }}
</div>
</div>
</div>
</div>
</div>
<!-- 工具提示 -->
<div
class="chart-tooltip"
v-if="tooltip.visible"
:style="tooltip.style"
>
<div class="tooltip-content">
<div class="tooltip-title">{{ tooltip.data.emotion }}</div>
<div class="tooltip-value">强度: {{ Math.round(tooltip.data.intensity * 100) }}%</div>
<div class="tooltip-time">{{ formatTime(tooltip.data.time) }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import {
LineChartOutlined,
BulbOutlined,
TrendingUpOutlined,
TrendingDownOutlined,
SmileOutlined,
FrownOutlined
} from '@ant-design/icons-vue'
import { formatTime } from '@/utils/format'
const props = defineProps({
data: {
type: Array,
required: true,
default: () => []
}
})
// 响应式数据
const selectedTimeRange = ref('30')
const chartContainer = ref(null)
const tooltip = ref({
visible: false,
data: {},
style: {}
})
// 情绪映射
const emotionMap = {
joy: '喜悦',
sadness: '悲伤',
anger: '愤怒',
fear: '恐惧',
surprise: '惊讶',
disgust: '厌恶',
trust: '信任',
anticipation: '期待',
anxiety: '焦虑',
depression: '抑郁',
excitement: '兴奋',
calm: '平静',
stress: '压力',
happiness: '快乐',
worry: '担忧',
relief: '放松',
frustration: '沮丧',
hope: '希望',
love: '爱',
hate: '恨'
}
const emotionColors = {
joy: '#ffd700',
happiness: '#ff9a9e',
excitement: '#ff6b6b',
love: '#ff6b9d',
trust: '#4facfe',
hope: '#43e97b',
calm: '#667eea',
relief: '#a8edea',
sadness: '#74b9ff',
depression: '#6c5ce7',
worry: '#fdcb6e',
anxiety: '#fd79a8',
stress: '#e84393',
anger: '#d63031',
frustration: '#e17055',
hate: '#2d3436',
fear: '#fd79a8',
surprise: '#00b894',
anticipation: '#74b9ff',
disgust: '#636e72'
}
// 计算属性
const filteredData = computed(() => {
const days = parseInt(selectedTimeRange.value)
const cutoffDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
return props.data.filter(item => new Date(item.analysisTime) >= cutoffDate)
})
const emotionDistribution = computed(() => {
const emotionCounts = {}
const total = filteredData.value.length
filteredData.value.forEach(item => {
const emotion = emotionMap[item.primaryEmotion] || item.primaryEmotion
emotionCounts[emotion] = (emotionCounts[emotion] || 0) + 1
})
return Object.entries(emotionCounts)
.map(([name, count]) => ({
name,
count,
percentage: Math.round((count / total) * 100),
color: emotionColors[Object.keys(emotionMap).find(key => emotionMap[key] === name)] || '#ccc'
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6) // 只显示前6个
})
const intensityTrend = computed(() => {
return filteredData.value
.sort((a, b) => new Date(a.analysisTime) - new Date(b.analysisTime))
.map(item => ({
time: item.analysisTime,
intensity: item.intensity || 0.5,
emotion: emotionMap[item.primaryEmotion] || item.primaryEmotion
}))
})
const polarityStats = computed(() => {
const polarityCounts = { positive: 0, negative: 0, neutral: 0 }
filteredData.value.forEach(item => {
polarityCounts[item.polarity] = (polarityCounts[item.polarity] || 0) + 1
})
const total = filteredData.value.length
return [
{
name: '积极',
count: polarityCounts.positive,
percentage: total ? Math.round((polarityCounts.positive / total) * 100) : 0,
color: '#52c41a'
},
{
name: '消极',
count: polarityCounts.negative,
percentage: total ? Math.round((polarityCounts.negative / total) * 100) : 0,
color: '#ff4d4f'
},
{
name: '中性',
count: polarityCounts.neutral,
percentage: total ? Math.round((polarityCounts.neutral / total) * 100) : 0,
color: '#d9d9d9'
}
]
})
const insights = computed(() => {
const insights = []
if (filteredData.value.length === 0) {
return [{
id: 'no-data',
icon: BulbOutlined,
text: '暂无足够数据进行分析',
suggestion: '继续记录您的情绪状态以获得更准确的分析'
}]
}
// 主要情绪分析
const topEmotion = emotionDistribution.value[0]
if (topEmotion) {
insights.push({
id: 'top-emotion',
icon: SmileOutlined,
text: `您最常出现的情绪是"${topEmotion.name}",占比${topEmotion.percentage}%`,
suggestion: topEmotion.percentage > 60 ? '情绪相对稳定,继续保持' : '情绪变化较为丰富'
})
}
// 情绪极性分析
const positiveRatio = polarityStats.value.find(p => p.name === '积极')?.percentage || 0
const negativeRatio = polarityStats.value.find(p => p.name === '消极')?.percentage || 0
if (positiveRatio > negativeRatio) {
insights.push({
id: 'positive-trend',
icon: TrendingUpOutlined,
text: `积极情绪占比${positiveRatio}%,整体心理状态良好`,
suggestion: '继续保持积极的生活态度'
})
} else if (negativeRatio > positiveRatio) {
insights.push({
id: 'negative-trend',
icon: FrownOutlined,
text: `消极情绪占比${negativeRatio}%,需要关注心理健康`,
suggestion: '建议适当调节情绪,必要时寻求专业帮助'
})
}
// 情绪强度分析
const avgIntensity = filteredData.value.reduce((sum, item) => sum + (item.intensity || 0.5), 0) / filteredData.value.length
if (avgIntensity > 0.7) {
insights.push({
id: 'high-intensity',
icon: TrendingUpOutlined,
text: `平均情绪强度较高(${Math.round(avgIntensity * 100)}%)`,
suggestion: '情绪波动较大,建议学习情绪管理技巧'
})
}
return insights
})
// 方法
const updateChart = () => {
// 图表更新逻辑
nextTick(() => {
// 重新渲染图表
})
}
const getPieSliceStyle = (emotion, index) => {
const total = emotionDistribution.value.reduce((sum, e) => sum + e.percentage, 0)
const angle = (emotion.percentage / total) * 360
const rotation = emotionDistribution.value
.slice(0, index)
.reduce((sum, e) => sum + (e.percentage / total) * 360, 0)
return {
background: `conic-gradient(from ${rotation}deg, ${emotion.color} 0deg, ${emotion.color} ${angle}deg, transparent ${angle}deg)`,
transform: `rotate(${rotation}deg)`
}
}
const getDataPointStyle = (point, index) => {
const maxIndex = intensityTrend.value.length - 1
const left = maxIndex > 0 ? (index / maxIndex) * 100 : 50
const bottom = point.intensity * 80 + 10 // 10% 到 90% 的范围
return {
left: `${left}%`,
bottom: `${bottom}%`
}
}
const showTooltip = (data, event) => {
tooltip.value = {
visible: true,
data,
style: {
left: `${event.clientX + 10}px`,
top: `${event.clientY - 10}px`
}
}
}
const hideTooltip = () => {
tooltip.value.visible = false
}
// 组件挂载
onMounted(() => {
updateChart()
})
</script>
<style lang="scss" scoped>
.emotion-trends {
.trends-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
.trends-title {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin: 0;
color: var(--text-primary);
}
}
.chart-container {
margin-bottom: var(--spacing-xl);
.simple-chart {
display: grid;
grid-template-columns: 1fr 2fr;
grid-template-rows: 1fr 1fr;
gap: var(--spacing-lg);
min-height: 400px;
.chart-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
text-align: center;
}
.emotion-distribution {
.pie-chart {
position: relative;
width: 150px;
height: 150px;
border-radius: 50%;
margin: 0 auto;
background: #f0f0f0;
.pie-slice {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
.slice-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
text-align: center;
color: var(--text-primary);
.slice-percentage {
display: block;
font-weight: 600;
}
}
}
}
}
.intensity-trend {
grid-column: 2;
grid-row: 1 / 3;
.line-chart {
position: relative;
height: 300px;
background: var(--bg-secondary);
border-radius: var(--border-radius);
padding: var(--spacing-md);
.chart-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
.grid-line {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: var(--border-color);
&:nth-child(1) { top: 10%; }
&:nth-child(2) { top: 30%; }
&:nth-child(3) { top: 50%; }
&:nth-child(4) { top: 70%; }
&:nth-child(5) { top: 90%; }
}
}
.trend-line {
position: relative;
height: 100%;
.data-point {
position: absolute;
width: 8px;
height: 8px;
cursor: pointer;
.point-marker {
width: 100%;
height: 100%;
border-radius: 50%;
background: var(--primary-color);
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
&:hover {
transform: scale(1.5);
background: var(--primary-dark);
}
}
}
}
}
}
.polarity-stats {
.bar-chart {
.bar-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
&:last-child {
margin-bottom: 0;
}
.bar-label {
font-size: 14px;
color: var(--text-primary);
min-width: 40px;
}
.bar-container {
flex: 1;
height: 20px;
background: var(--bg-secondary);
border-radius: 10px;
overflow: hidden;
.bar-fill {
height: 100%;
border-radius: 10px;
transition: width 0.3s ease;
}
}
.bar-value {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
min-width: 30px;
text-align: right;
}
}
}
}
}
}
.insights-section {
.insights-title {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
.insights-list {
.insight-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: var(--bg-secondary);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-md);
&:last-child {
margin-bottom: 0;
}
.insight-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
flex-shrink: 0;
}
.insight-content {
flex: 1;
.insight-text {
font-size: 14px;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
line-height: 1.5;
}
.insight-suggestion {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
}
}
}
}
.chart-tooltip {
position: fixed;
z-index: 1000;
background: var(--bg-dark);
color: white;
padding: var(--spacing-sm);
border-radius: var(--border-radius-small);
box-shadow: var(--box-shadow);
font-size: 12px;
pointer-events: none;
.tooltip-content {
.tooltip-title {
font-weight: 600;
margin-bottom: 2px;
}
.tooltip-value,
.tooltip-time {
opacity: 0.8;
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.emotion-trends {
.chart-container {
.simple-chart {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: var(--spacing-md);
.intensity-trend {
grid-column: 1;
grid-row: auto;
.line-chart {
height: 200px;
}
}
}
}
}
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<template>
<div v-if="showEnvInfo" class="env-info">
<a-card
title="环境信息"
size="small"
:style="{ position: 'fixed', top: '10px', right: '10px', zIndex: 9999, width: '300px' }"
>
<template #extra>
<a-button size="small" @click="toggleVisible">
{{ visible ? '隐藏' : '显示' }}
</a-button>
</template>
<div v-show="visible" class="env-details">
<a-descriptions size="small" :column="1" bordered>
<a-descriptions-item label="环境">
<a-tag :color="envColor">{{ ENV_CONFIG.APP_ENV }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="标题">
{{ ENV_CONFIG.APP_TITLE }}
</a-descriptions-item>
<a-descriptions-item label="版本">
{{ ENV_CONFIG.APP_VERSION }}
</a-descriptions-item>
<a-descriptions-item label="API地址">
<code>{{ ENV_CONFIG.API_BASE_URL }}</code>
</a-descriptions-item>
<a-descriptions-item label="API目标">
<code>{{ ENV_CONFIG.API_TARGET }}</code>
</a-descriptions-item>
<a-descriptions-item label="超时时间">
{{ ENV_CONFIG.API_TIMEOUT }}ms
</a-descriptions-item>
<a-descriptions-item label="调试模式">
<a-tag :color="ENV_CONFIG.DEBUG_MODE ? 'green' : 'red'">
{{ ENV_CONFIG.DEBUG_MODE ? '开启' : '关闭' }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
<div class="env-actions" style="margin-top: 12px;">
<a-space>
<a-button size="small" @click="printEnvInfo">
打印到控制台
</a-button>
<a-button size="small" @click="copyEnvInfo">
复制信息
</a-button>
</a-space>
</div>
</div>
</a-card>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { ENV_CONFIG, printEnvInfo as logEnvInfo } from '@/config/env'
// 只在非生产环境显示
const showEnvInfo = computed(() => !ENV_CONFIG.isProduction)
const visible = ref(false)
// 环境颜色
const envColor = computed(() => {
switch (ENV_CONFIG.APP_ENV) {
case 'development':
return 'blue'
case 'test':
return 'orange'
case 'production':
return 'green'
default:
return 'default'
}
})
// 切换显示/隐藏
const toggleVisible = () => {
visible.value = !visible.value
}
// 打印环境信息到控制台
const printEnvInfo = () => {
logEnvInfo()
message.success('环境信息已打印到控制台')
}
// 复制环境信息
const copyEnvInfo = async () => {
const info = `
环境: ${ENV_CONFIG.APP_ENV}
标题: ${ENV_CONFIG.APP_TITLE}
版本: ${ENV_CONFIG.APP_VERSION}
API地址: ${ENV_CONFIG.API_BASE_URL}
API目标: ${ENV_CONFIG.API_TARGET}
超时时间: ${ENV_CONFIG.API_TIMEOUT}ms
调试模式: ${ENV_CONFIG.DEBUG_MODE ? '开启' : '关闭'}
`.trim()
try {
await navigator.clipboard.writeText(info)
message.success('环境信息已复制到剪贴板')
} catch (error) {
message.error('复制失败')
}
}
</script>
<style scoped>
.env-info {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.env-details code {
background: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-size: 12px;
}
.env-actions {
text-align: center;
}
</style>
+492
View File
@@ -0,0 +1,492 @@
<template>
<div class="history-panel">
<!-- 搜索和筛选 -->
<div class="history-filters">
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索对话内容..."
@search="handleSearch"
class="search-input"
/>
<div class="filter-options">
<a-select
v-model:value="selectedTimeRange"
placeholder="时间范围"
style="width: 120px"
@change="handleTimeRangeChange"
>
<a-select-option value="today">今天</a-select-option>
<a-select-option value="week">本周</a-select-option>
<a-select-option value="month">本月</a-select-option>
<a-select-option value="all">全部</a-select-option>
</a-select>
<a-select
v-model:value="selectedType"
placeholder="对话类型"
style="width: 120px"
@change="handleTypeChange"
>
<a-select-option value="all">全部类型</a-select-option>
<a-select-option value="emotion_chat">情绪对话</a-select-option>
<a-select-option value="general">一般对话</a-select-option>
</a-select>
</div>
</div>
<!-- 历史记录列表 -->
<div class="history-list">
<a-spin :spinning="loading">
<div class="history-items" v-if="filteredConversations.length > 0">
<div
class="history-item"
v-for="conversation in filteredConversations"
:key="conversation.conversationId"
@click="viewConversation(conversation)"
>
<div class="item-header">
<div class="item-title">{{ conversation.title }}</div>
<div class="item-time">{{ formatTime(conversation.updateTime) }}</div>
</div>
<div class="item-content">
<div class="item-preview" v-if="conversation.lastMessage">
{{ conversation.lastMessage }}
</div>
<div class="item-stats">
<a-tag size="small" color="blue">
<MessageOutlined />
{{ conversation.messageCount || 0 }} 条消息
</a-tag>
<a-tag
size="small"
:color="getStatusColor(conversation.status)"
>
{{ getStatusText(conversation.status) }}
</a-tag>
</div>
</div>
<div class="item-actions">
<a-button
type="text"
size="small"
@click.stop="continueConversation(conversation)"
>
<PlayCircleOutlined />
继续对话
</a-button>
<a-button
type="text"
size="small"
@click.stop="exportConversation(conversation)"
>
<DownloadOutlined />
导出
</a-button>
<a-popconfirm
title="确定要删除这个对话吗?"
@confirm="deleteConversation(conversation.conversationId)"
@click.stop
>
<a-button
type="text"
size="small"
danger
>
<DeleteOutlined />
删除
</a-button>
</a-popconfirm>
</div>
</div>
</div>
<div class="empty-history" v-else>
<HistoryOutlined class="empty-icon" />
<p>{{ getEmptyText() }}</p>
</div>
</a-spin>
</div>
<!-- 分页 -->
<div class="history-pagination" v-if="filteredConversations.length > 0">
<a-pagination
v-model:current="currentPage"
v-model:page-size="pageSize"
:total="totalCount"
:show-size-changer="false"
:show-quick-jumper="true"
size="small"
@change="handlePageChange"
/>
</div>
<!-- 对话详情模态框 -->
<a-modal
v-model:open="showConversationDetail"
:title="selectedConversation?.title"
width="800px"
:footer="null"
class="conversation-detail-modal"
>
<ConversationDetail
v-if="selectedConversation"
:conversation="selectedConversation"
@continue="continueFromDetail"
/>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
MessageOutlined,
PlayCircleOutlined,
DownloadOutlined,
DeleteOutlined,
HistoryOutlined
} from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user'
import { useChatStore } from '@/stores/chat'
import { formatTime } from '@/utils/format'
import ConversationDetail from './ConversationDetail.vue'
const router = useRouter()
const userStore = useUserStore()
const chatStore = useChatStore()
// 响应式数据
const loading = ref(false)
const searchKeyword = ref('')
const selectedTimeRange = ref('all')
const selectedType = ref('all')
const currentPage = ref(1)
const pageSize = ref(10)
const totalCount = ref(0)
const showConversationDetail = ref(false)
const selectedConversation = ref(null)
// 计算属性
const filteredConversations = computed(() => {
let conversations = [...chatStore.conversations]
// 关键词搜索
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
conversations = conversations.filter(conv =>
conv.title.toLowerCase().includes(keyword) ||
(conv.lastMessage && conv.lastMessage.toLowerCase().includes(keyword))
)
}
// 时间范围筛选
if (selectedTimeRange.value !== 'all') {
const now = new Date()
const filterDate = new Date()
switch (selectedTimeRange.value) {
case 'today':
filterDate.setHours(0, 0, 0, 0)
break
case 'week':
filterDate.setDate(now.getDate() - 7)
break
case 'month':
filterDate.setMonth(now.getMonth() - 1)
break
}
conversations = conversations.filter(conv =>
new Date(conv.updateTime) >= filterDate
)
}
// 类型筛选
if (selectedType.value !== 'all') {
conversations = conversations.filter(conv =>
conv.type === selectedType.value
)
}
// 排序
conversations.sort((a, b) =>
new Date(b.updateTime) - new Date(a.updateTime)
)
totalCount.value = conversations.length
// 分页
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return conversations.slice(start, end)
})
// 方法
const handleSearch = () => {
currentPage.value = 1
}
const handleTimeRangeChange = () => {
currentPage.value = 1
}
const handleTypeChange = () => {
currentPage.value = 1
}
const handlePageChange = (page) => {
currentPage.value = page
}
const getStatusColor = (status) => {
const colorMap = {
active: 'success',
ended: 'default',
archived: 'warning'
}
return colorMap[status] || 'default'
}
const getStatusText = (status) => {
const textMap = {
active: '进行中',
ended: '已结束',
archived: '已归档'
}
return textMap[status] || status
}
const getEmptyText = () => {
if (searchKeyword.value) {
return '没有找到匹配的对话记录'
}
if (selectedTimeRange.value !== 'all' || selectedType.value !== 'all') {
return '当前筛选条件下没有对话记录'
}
return '暂无对话历史记录'
}
const viewConversation = (conversation) => {
selectedConversation.value = conversation
showConversationDetail.value = true
}
const continueConversation = async (conversation) => {
try {
await chatStore.switchConversation(conversation)
router.push('/chat')
message.success('已切换到该对话')
} catch (error) {
console.error('切换对话失败:', error)
}
}
const continueFromDetail = () => {
showConversationDetail.value = false
continueConversation(selectedConversation.value)
}
const exportConversation = async (conversation) => {
try {
// 获取对话详细消息
const messages = await chatStore.fetchMessages(conversation.conversationId)
// 生成导出内容
const exportContent = generateExportContent(conversation, messages)
// 创建下载链接
const blob = new Blob([exportContent], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${conversation.title}_${formatTime(conversation.createTime, 'YYYY-MM-DD')}.txt`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
message.success('对话已导出')
} catch (error) {
console.error('导出对话失败:', error)
message.error('导出失败')
}
}
const generateExportContent = (conversation, messages) => {
let content = `对话标题: ${conversation.title}\n`
content += `创建时间: ${formatTime(conversation.createTime)}\n`
content += `更新时间: ${formatTime(conversation.updateTime)}\n`
content += `消息数量: ${conversation.messageCount || 0}\n`
content += `对话状态: ${getStatusText(conversation.status)}\n`
content += '\n' + '='.repeat(50) + '\n\n'
messages.forEach(message => {
const sender = message.sender === 'user' ? '用户' : 'AI助手'
content += `[${formatTime(message.timestamp)}] ${sender}:\n`
content += `${message.content}\n\n`
if (message.emotionAnalysis) {
content += `情绪分析: ${message.emotionAnalysis.primaryEmotion || '未知'}\n\n`
}
})
return content
}
const deleteConversation = async (conversationId) => {
try {
await chatStore.deleteConversation(conversationId)
message.success('对话已删除')
} catch (error) {
console.error('删除对话失败:', error)
}
}
const refreshHistory = async () => {
try {
loading.value = true
await chatStore.fetchConversations(userStore.userInfo.id)
} catch (error) {
console.error('刷新历史记录失败:', error)
} finally {
loading.value = false
}
}
// 监听搜索关键词变化
watch(searchKeyword, () => {
currentPage.value = 1
})
// 组件挂载
onMounted(() => {
refreshHistory()
})
</script>
<style lang="scss" scoped>
.history-panel {
height: 100%;
display: flex;
flex-direction: column;
.history-filters {
margin-bottom: var(--spacing-lg);
.search-input {
margin-bottom: var(--spacing-md);
}
.filter-options {
display: flex;
gap: var(--spacing-sm);
}
}
.history-list {
flex: 1;
overflow-y: auto;
.history-items {
.history-item {
padding: var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-md);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-sm);
.item-title {
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: var(--spacing-sm);
}
.item-time {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
}
.item-content {
margin-bottom: var(--spacing-sm);
.item-preview {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.4;
margin-bottom: var(--spacing-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-stats {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
}
}
.item-actions {
display: flex;
gap: var(--spacing-xs);
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover .item-actions {
opacity: 1;
}
}
}
.empty-history {
text-align: center;
padding: var(--spacing-xxl);
color: var(--text-secondary);
.empty-icon {
font-size: 48px;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
}
}
.history-pagination {
margin-top: var(--spacing-lg);
text-align: center;
border-top: 1px solid var(--border-color);
padding-top: var(--spacing-md);
}
}
:deep(.conversation-detail-modal) {
.ant-modal-body {
max-height: 60vh;
overflow-y: auto;
}
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<template>
<div class="slider-captcha">
<div class="captcha-panel">
<div class="background-container" ref="backgroundRef">
<img
v-if="backgroundImage"
:src="backgroundImage"
alt="背景图"
class="background-image"
/>
<img
v-if="sliderImage"
:src="sliderImage"
alt="滑块"
class="slider-image"
:style="sliderStyle"
/>
</div>
<div class="slider-track">
<div class="slider-track-bg">
<span class="slider-text">{{ sliderText }}</span>
</div>
<div
class="slider-btn"
:class="{ 'sliding': isSliding, 'success': isSuccess, 'error': isError }"
:style="{ left: sliderPosition + 'px' }"
@mousedown="startSlide"
@touchstart="startSlide"
>
<RightOutlined v-if="!isSuccess && !isError" />
<CheckOutlined v-if="isSuccess" />
<CloseOutlined v-if="isError" />
</div>
</div>
</div>
<div class="captcha-actions">
<a-button type="link" size="small" @click="refresh">
<ReloadOutlined />
刷新
</a-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import {
RightOutlined,
CheckOutlined,
CloseOutlined,
ReloadOutlined
} from '@ant-design/icons-vue'
import { captchaApi } from '@/api/captcha'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'success', 'error'])
// 响应式数据
const backgroundRef = ref(null)
const backgroundImage = ref('')
const sliderImage = ref('')
const captchaId = ref('')
const sliderPosition = ref(0)
const isSliding = ref(false)
const isSuccess = ref(false)
const isError = ref(false)
const startX = ref(0)
const currentX = ref(0)
const targetX = ref(0)
// 计算属性
const sliderStyle = computed(() => ({
left: sliderPosition.value + 'px'
}))
const sliderText = computed(() => {
if (isSuccess.value) return '验证成功'
if (isError.value) return '验证失败,请重试'
if (isSliding.value) return '松开完成验证'
return '向右滑动完成验证'
})
// 生成滑块验证码
const generateCaptcha = async () => {
try {
const response = await captchaApi.generateSlider()
if (response.success) {
const data = response.data
backgroundImage.value = data.backgroundImage
sliderImage.value = data.sliderImage
captchaId.value = data.captchaId
targetX.value = data.sliderX || 0
resetState()
}
} catch (error) {
console.error('生成滑块验证码失败:', error)
}
}
// 重置状态
const resetState = () => {
sliderPosition.value = 0
isSliding.value = false
isSuccess.value = false
isError.value = false
currentX.value = 0
}
// 开始滑动
const startSlide = (e) => {
if (isSuccess.value || isError.value) return
isSliding.value = true
startX.value = e.type === 'mousedown' ? e.clientX : e.touches[0].clientX
document.addEventListener('mousemove', onSlide)
document.addEventListener('mouseup', endSlide)
document.addEventListener('touchmove', onSlide)
document.addEventListener('touchend', endSlide)
}
// 滑动中
const onSlide = (e) => {
if (!isSliding.value) return
e.preventDefault()
const clientX = e.type === 'mousemove' ? e.clientX : e.touches[0].clientX
const deltaX = clientX - startX.value
const maxDistance = 240 // 滑动轨道长度 - 滑块宽度
currentX.value = Math.max(0, Math.min(deltaX, maxDistance))
sliderPosition.value = currentX.value
}
// 结束滑动
const endSlide = async () => {
if (!isSliding.value) return
isSliding.value = false
// 移除事件监听
document.removeEventListener('mousemove', onSlide)
document.removeEventListener('mouseup', endSlide)
document.removeEventListener('touchmove', onSlide)
document.removeEventListener('touchend', endSlide)
// 验证滑块位置
await verifyCaptcha()
}
// 验证滑块验证码
const verifyCaptcha = async () => {
try {
const response = await captchaApi.verifySlider({
captchaId: captchaId.value,
x: Math.round(currentX.value),
y: 0
})
if (response.success && response.data) {
// 验证成功
isSuccess.value = true
emit('update:modelValue', true)
emit('success', captchaId.value)
} else {
// 验证失败
isError.value = true
emit('update:modelValue', false)
emit('error')
// 2秒后重置
setTimeout(() => {
refresh()
}, 2000)
}
} catch (error) {
console.error('验证滑块验证码失败:', error)
isError.value = true
emit('error')
setTimeout(() => {
refresh()
}, 2000)
}
}
// 刷新验证码
const refresh = () => {
resetState()
generateCaptcha()
}
// 组件挂载
onMounted(() => {
generateCaptcha()
})
// 组件卸载
onUnmounted(() => {
document.removeEventListener('mousemove', onSlide)
document.removeEventListener('mouseup', endSlide)
document.removeEventListener('touchmove', onSlide)
document.removeEventListener('touchend', endSlide)
})
// 暴露方法
defineExpose({
refresh,
getCaptchaId: () => captchaId.value,
isValid: () => isSuccess.value
})
</script>
<style lang="scss" scoped>
.slider-captcha {
width: 100%;
max-width: 300px;
}
.captcha-panel {
border: 1px solid #e8e8e8;
border-radius: 4px;
overflow: hidden;
background: #f7f7f7;
}
.background-container {
position: relative;
width: 300px;
height: 150px;
overflow: hidden;
}
.background-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.slider-image {
position: absolute;
top: 45px;
width: 60px;
height: 60px;
transition: left 0.1s ease;
z-index: 2;
}
.slider-track {
position: relative;
height: 40px;
background: #f7f7f7;
border-top: 1px solid #e8e8e8;
}
.slider-track-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(to right, #1890ff, #40a9ff);
border-radius: 0 0 4px 4px;
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s ease;
}
.slider-text {
color: #666;
font-size: 14px;
user-select: none;
position: absolute;
width: 100%;
text-align: center;
line-height: 40px;
}
.slider-btn {
position: absolute;
top: 5px;
left: 5px;
width: 30px;
height: 30px;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
z-index: 3;
&:hover {
border-color: #1890ff;
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2);
}
&.sliding {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.3);
}
&.success {
background: #52c41a;
border-color: #52c41a;
color: white;
}
&.error {
background: #ff4d4f;
border-color: #ff4d4f;
color: white;
}
}
.captcha-actions {
padding: 8px;
text-align: right;
border-top: 1px solid #f0f0f0;
}
// 滑动时的轨道效果
.slider-track:has(.slider-btn.sliding) .slider-track-bg {
transform: scaleX(1);
}
.slider-track:has(.slider-btn.success) .slider-track-bg {
transform: scaleX(1);
background: linear-gradient(to right, #52c41a, #73d13d);
}
.slider-track:has(.slider-btn.error) .slider-track-bg {
transform: scaleX(1);
background: linear-gradient(to right, #ff4d4f, #ff7875);
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<template>
<div class="social-login">
<div class="social-title">
<span>第三方登录</span>
</div>
<div class="social-buttons">
<a-tooltip title="微信登录">
<div
class="social-btn wechat"
@click="handleSocialLogin('wechat')"
:class="{ loading: loadingPlatform === 'wechat' }"
>
<a-spin v-if="loadingPlatform === 'wechat'" size="small" />
<svg v-else class="social-icon" viewBox="0 0 1024 1024">
<path d="M690.1 377.4c5.2 0 10.3 0.2 15.4 0.7-13.8-64.1-83.1-112.1-164.5-112.1-98.7 0-178.5 63.7-178.5 142.1 0 45.8 24.3 83.8 65.2 113.4l-16.1 48.4 56.4-28.2c20.1 4 36.2 8.1 56.4 8.1 5.6 0 11.1-0.3 16.6-0.8-3.5-11.8-5.4-24.1-5.4-36.8-0.1-76.8 59.2-139.8 154.5-139.8z m-98.9-39.9c12.1 0 20.1 8.1 20.1 20.2s-8.1 20.2-20.1 20.2c-12.1 0-24.3-8.1-24.3-20.2s12.2-20.2 24.3-20.2z m-112.6 40.4c-12.1 0-24.3-8.1-24.3-20.2s12.2-20.2 24.3-20.2 20.1 8.1 20.1 20.2-8 20.2-20.1 20.2z" fill="#07C160"/>
<path d="M866.7 620.3c0-68.1-68.4-122.1-152.3-122.1s-152.3 54-152.3 122.1 68.4 122.1 152.3 122.1c16.1 0 32.3-4 48.4-8.1l44.3 24.3-12.1-40.4c28.2-20.1 73.7-56.4 73.7-97.9z m-194.6-20.2c-8.1 0-16.1-8.1-16.1-16.1 0-8.1 8.1-16.1 16.1-16.1s16.1 8.1 16.1 16.1c0 8.1-8 16.1-16.1 16.1z m80.5 0c-8.1 0-16.1-8.1-16.1-16.1 0-8.1 8.1-16.1 16.1-16.1s16.1 8.1 16.1 16.1c0 8.1-8 16.1-16.1 16.1z" fill="#07C160"/>
</svg>
</div>
</a-tooltip>
<a-tooltip title="QQ登录">
<div
class="social-btn qq"
@click="handleSocialLogin('qq')"
:class="{ loading: loadingPlatform === 'qq' }"
>
<a-spin v-if="loadingPlatform === 'qq'" size="small" />
<svg v-else class="social-icon" viewBox="0 0 1024 1024">
<path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64z m0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z" fill="#12B7F5"/>
<path d="M512 140c-205.4 0-372 166.6-372 372 0 89.4 31.6 171.4 84.2 235.4 0.4-2.6 1.2-5.1 2.4-7.4 3.2-6.1 8.4-10.5 14.6-12.4 27.8-8.4 48.2-32.6 54.2-61.2 1.4-6.6 6.2-12.2 12.6-14.8 6.4-2.6 13.6-2.2 19.6 1.2 24.2 13.6 52.2 20.8 81 20.8s56.8-7.2 81-20.8c6-3.4 13.2-3.8 19.6-1.2 6.4 2.6 11.2 8.2 12.6 14.8 6 28.6 26.4 52.8 54.2 61.2 6.2 1.9 11.4 6.3 14.6 12.4 1.2 2.3 2 4.8 2.4 7.4C852.4 683.4 884 601.4 884 512c0-205.4-166.6-372-372-372z" fill="#12B7F5"/>
</svg>
</div>
</a-tooltip>
<a-tooltip title="微信公众号登录">
<div
class="social-btn wechat-mp"
@click="handleSocialLogin('wechat-mp')"
:class="{ loading: loadingPlatform === 'wechat-mp' }"
>
<a-spin v-if="loadingPlatform === 'wechat-mp'" size="small" />
<svg v-else class="social-icon" viewBox="0 0 1024 1024">
<path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64z m0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z" fill="#07C160"/>
<path d="M623.5 421.5c-49.9 0-90.4 40.5-90.4 90.4s40.5 90.4 90.4 90.4 90.4-40.5 90.4-90.4-40.5-90.4-90.4-90.4z m-223 0c-49.9 0-90.4 40.5-90.4 90.4s40.5 90.4 90.4 90.4 90.4-40.5 90.4-90.4-40.5-90.4-90.4-90.4z" fill="#07C160"/>
</svg>
</div>
</a-tooltip>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { oauthApi } from '@/api/oauth'
import { useUserStore } from '@/stores/user'
const emit = defineEmits(['success', 'error'])
const userStore = useUserStore()
const loadingPlatform = ref('')
// 处理第三方登录
const handleSocialLogin = async (platform) => {
try {
loadingPlatform.value = platform
// 获取授权URL
const response = await oauthApi.getAuthUrl(platform)
if (response.success) {
// 打开新窗口进行授权
const authWindow = window.open(
response.data,
'oauth_login',
'width=500,height=600,scrollbars=yes,resizable=yes'
)
// 监听授权回调
const checkClosed = setInterval(() => {
if (authWindow.closed) {
clearInterval(checkClosed)
loadingPlatform.value = ''
}
}, 1000)
// 监听消息
const messageHandler = (event) => {
if (event.origin !== window.location.origin) return
if (event.data.type === 'oauth_success') {
clearInterval(checkClosed)
authWindow.close()
handleOAuthCallback(platform, event.data)
} else if (event.data.type === 'oauth_error') {
clearInterval(checkClosed)
authWindow.close()
loadingPlatform.value = ''
message.error(event.data.message || '第三方登录失败')
emit('error', event.data.message)
}
}
window.addEventListener('message', messageHandler)
// 清理事件监听
setTimeout(() => {
window.removeEventListener('message', messageHandler)
if (!authWindow.closed) {
authWindow.close()
loadingPlatform.value = ''
}
}, 300000) // 5分钟超时
} else {
message.error(response.message || '获取授权链接失败')
loadingPlatform.value = ''
}
} catch (error) {
console.error('第三方登录失败:', error)
message.error('第三方登录失败')
loadingPlatform.value = ''
emit('error', error.message)
}
}
// 处理OAuth回调
const handleOAuthCallback = async (platform, data) => {
try {
// 这里需要验证码,可以弹出验证码对话框
// 为简化演示,这里直接使用模拟的验证码
const loginData = {
platform,
code: data.code,
state: data.state,
captchaId: 'mock_captcha_id',
captcha: 'mock_captcha'
}
const response = await oauthApi.login(loginData)
if (response.success) {
const { accessToken, refreshToken, userInfo } = response.data
// 存储token
localStorage.setItem('token', accessToken)
localStorage.setItem('refreshToken', refreshToken)
// 更新用户状态
userStore.setUser(userInfo)
message.success('登录成功')
emit('success', userInfo)
} else {
message.error(response.message || '第三方登录失败')
emit('error', response.message)
}
} catch (error) {
console.error('处理OAuth回调失败:', error)
message.error('登录失败')
emit('error', error.message)
} finally {
loadingPlatform.value = ''
}
}
</script>
<style lang="scss" scoped>
.social-login {
margin-top: 24px;
}
.social-title {
text-align: center;
margin-bottom: 16px;
position: relative;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
width: 60px;
height: 1px;
background: rgba(255, 255, 255, 0.3);
}
&::before {
left: 0;
}
&::after {
right: 0;
}
span {
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
padding: 0 16px;
background: transparent;
}
}
.social-buttons {
display: flex;
justify-content: center;
gap: 16px;
}
.social-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&.loading {
cursor: not-allowed;
opacity: 0.6;
}
&.wechat {
background: linear-gradient(135deg, #07C160, #38d9a9);
&:hover {
background: linear-gradient(135deg, #06ad56, #20c997);
}
}
&.qq {
background: linear-gradient(135deg, #12B7F5, #40a9ff);
&:hover {
background: linear-gradient(135deg, #0ea5e9, #1890ff);
}
}
&.wechat-mp {
background: linear-gradient(135deg, #07C160, #52c41a);
&:hover {
background: linear-gradient(135deg, #06ad56, #389e0d);
}
}
}
.social-icon {
width: 20px;
height: 20px;
fill: white;
}
</style>
+61
View File
@@ -0,0 +1,61 @@
/**
* 环境配置管理
*/
// 获取环境变量
const getEnvConfig = () => {
return {
// 应用基础信息
APP_TITLE: import.meta.env.VITE_APP_TITLE || '情绪博物馆',
APP_VERSION: import.meta.env.VITE_APP_VERSION || '1.0.0',
APP_ENV: import.meta.env.VITE_APP_ENV || 'development',
// API配置
API_BASE_URL: import.meta.env.VITE_API_BASE_URL || '/api',
API_TARGET: import.meta.env.VITE_API_TARGET || 'http://localhost:9000',
API_TIMEOUT: parseInt(import.meta.env.VITE_API_TIMEOUT) || 30000,
// 功能开关
DEBUG_MODE: import.meta.env.VITE_DEBUG_MODE === 'true',
MOCK_DATA: import.meta.env.VITE_MOCK_DATA === 'true',
// 环境判断
isDevelopment: import.meta.env.MODE === 'development',
isTest: import.meta.env.MODE === 'test',
isProduction: import.meta.env.MODE === 'production'
}
}
// 导出配置
export const ENV_CONFIG = getEnvConfig()
// 环境检查函数
export const isDev = () => ENV_CONFIG.isDevelopment
export const isTest = () => ENV_CONFIG.isTest
export const isProd = () => ENV_CONFIG.isProduction
// 调试日志函数
export const debugLog = (...args) => {
if (ENV_CONFIG.DEBUG_MODE) {
console.log('[DEBUG]', ...args)
}
}
// 获取完整的API URL
export const getApiUrl = (path = '') => {
// 所有环境都使用相对路径,通过nginx代理
return `${ENV_CONFIG.API_BASE_URL}${path}`
}
// 打印环境信息
export const printEnvInfo = () => {
console.log('=== 环境配置信息 ===')
console.log('应用标题:', ENV_CONFIG.APP_TITLE)
console.log('应用版本:', ENV_CONFIG.APP_VERSION)
console.log('运行环境:', ENV_CONFIG.APP_ENV)
console.log('API地址:', ENV_CONFIG.API_BASE_URL)
console.log('调试模式:', ENV_CONFIG.DEBUG_MODE)
console.log('==================')
}
export default ENV_CONFIG
+61
View File
@@ -0,0 +1,61 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
// Ant Design Vue
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
// 全局样式
import './styles/global.scss'
// 环境配置
import { ENV_CONFIG, printEnvInfo, debugLog } from '@/config/env'
// 用户store
import { useUserStore } from '@/stores/user'
// 认证工具
import { setupTokenRefreshTimer } from '@/utils/auth'
debugLog('main.js loading...')
// 打印环境信息
if (ENV_CONFIG.DEBUG_MODE) {
printEnvInfo()
}
// 创建应用实例
const app = createApp(App)
debugLog('App created')
// 使用插件
app.use(createPinia())
app.use(router)
app.use(Antd)
debugLog('Plugins loaded')
// 初始化用户store
try {
const userStore = useUserStore()
userStore.initUser()
debugLog('User store initialized successfully')
} catch (error) {
console.error('User store initialization failed:', error)
debugLog('User store initialization failed, using guest mode')
}
// 设置应用标题
document.title = ENV_CONFIG.APP_TITLE
// 挂载应用
app.mount('#app')
debugLog('App mounted')
// 启动token自动刷新定时器
setupTokenRefreshTimer()
debugLog('Token refresh timer started')
+84
View File
@@ -0,0 +1,84 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: {
title: '情绪博物馆 - 首页'
}
},
{
path: '/test',
name: 'Test',
component: () => import('@/views/HomeTest.vue'),
meta: {
title: '情绪博物馆 - 测试页面'
}
},
{
path: '/chat',
name: 'Chat',
component: () => import('@/views/ChatComplete.vue'),
meta: {
title: 'AI对话 - 情绪博物馆'
}
},
{
path: '/history',
name: 'History',
component: () => import('@/views/HistorySimple.vue'),
meta: {
title: '对话历史 - 情绪博物馆'
}
},
{
path: '/analysis',
name: 'Analysis',
component: () => import('@/views/AnalysisSimple.vue'),
meta: {
title: '情绪分析 - 情绪博物馆'
}
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: {
title: '登录注册 - 情绪博物馆'
}
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
// 设置页面标题
if (to.meta.title) {
document.title = to.meta.title
}
// 检查是否需要认证
const requiresAuth = to.meta.requiresAuth
const token = localStorage.getItem('token')
if (requiresAuth && !token) {
// 需要认证但没有token,跳转到登录页
next({
path: '/login',
query: { redirect: to.fullPath }
})
} else if (to.path === '/login' && token) {
// 已登录用户访问登录页,跳转到首页
next('/')
} else {
next()
}
})
export default router
+197
View File
@@ -0,0 +1,197 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { chatApi } from '@/api/chat'
import { message } from 'ant-design-vue'
export const useChatStore = defineStore('chat', () => {
const conversations = ref([])
const currentConversation = ref(null)
const messages = ref([])
const loading = ref(false)
const typing = ref(false)
// 计算属性
const hasConversations = computed(() => conversations.value.length > 0)
const currentConversationId = computed(() => currentConversation.value?.conversationId)
// 获取会话列表
const fetchConversations = async (userId) => {
try {
const response = await chatApi.getConversations(userId)
if (response.success) {
conversations.value = response.data || []
}
} catch (error) {
console.error('获取会话列表失败:', error)
message.error('获取会话列表失败')
}
}
// 创建新会话
const createConversation = async (params) => {
try {
loading.value = true
console.log('创建会话请求参数:', params)
const response = await chatApi.createConversation(params)
console.log('创建会话响应:', response)
if (response.success) {
const newConversation = response.data
// 确保会话对象有必要的属性
const conversation = {
conversationId: newConversation.conversationId,
userId: newConversation.userId,
title: newConversation.title || '新对话',
type: newConversation.type || 'emotion_chat',
status: newConversation.status || 'active',
createTime: newConversation.createTime || new Date().toISOString(),
updateTime: newConversation.updateTime || new Date().toISOString(),
messageCount: 0
}
conversations.value.unshift(conversation)
currentConversation.value = conversation
messages.value = []
return conversation
}
throw new Error(response.message || '创建会话失败')
} catch (error) {
console.error('创建会话失败:', error)
message.error(error.message || '创建会话失败')
throw error
} finally {
loading.value = false
}
}
// 发送消息
const sendMessage = async (content, needEmotionAnalysis = true) => {
if (!currentConversation.value) {
message.error('请先创建会话')
return
}
try {
typing.value = true
// 添加用户消息到界面
const userMessage = {
id: `user_${Date.now()}`,
content,
sender: 'user',
timestamp: new Date(),
type: 'text'
}
messages.value.push(userMessage)
console.log('添加用户消息:', userMessage)
// 发送到后端
const requestData = {
userId: currentConversation.value.userId,
conversationId: currentConversation.value.conversationId,
message: content,
needEmotionAnalysis,
type: 'text'
}
console.log('发送消息请求:', requestData)
const response = await chatApi.sendMessage(requestData)
console.log('发送消息响应:', response)
if (response.success) {
// 添加AI回复到界面
const aiMessage = {
id: response.data.messageId || `ai_${Date.now()}`,
content: response.data.content,
sender: 'assistant',
timestamp: response.data.timestamp ? new Date(response.data.timestamp) : new Date(),
type: response.data.type || 'text',
emotionAnalysis: response.data.emotionAnalysis
}
messages.value.push(aiMessage)
console.log('添加AI消息:', aiMessage)
// 更新会话的最后更新时间和消息数量
if (currentConversation.value) {
currentConversation.value.updateTime = new Date().toISOString()
currentConversation.value.messageCount = (currentConversation.value.messageCount || 0) + 2
}
return aiMessage
}
throw new Error(response.message || '发送消息失败')
} catch (error) {
console.error('发送消息失败:', error)
message.error(error.message || '发送消息失败')
// 移除失败的用户消息
messages.value = messages.value.filter(msg => msg.id !== `user_${Date.now()}`)
throw error
} finally {
typing.value = false
}
}
// 获取会话消息
const fetchMessages = async (conversationId) => {
try {
loading.value = true
const response = await chatApi.getMessages(conversationId)
if (response.success) {
messages.value = response.data || []
}
} catch (error) {
console.error('获取消息失败:', error)
message.error('获取消息失败')
} finally {
loading.value = false
}
}
// 切换会话
const switchConversation = async (conversation) => {
currentConversation.value = conversation
await fetchMessages(conversation.conversationId)
}
// 清空当前会话
const clearCurrentConversation = () => {
currentConversation.value = null
messages.value = []
}
// 删除会话
const deleteConversation = async (conversationId) => {
try {
await chatApi.deleteConversation(conversationId)
conversations.value = conversations.value.filter(c => c.conversationId !== conversationId)
if (currentConversation.value?.conversationId === conversationId) {
clearCurrentConversation()
}
message.success('删除成功')
} catch (error) {
console.error('删除会话失败:', error)
message.error('删除会话失败')
}
}
return {
conversations,
currentConversation,
messages,
loading,
typing,
hasConversations,
currentConversationId,
fetchConversations,
createConversation,
sendMessage,
fetchMessages,
switchConversation,
clearCurrentConversation,
deleteConversation
}
})
+270
View File
@@ -0,0 +1,270 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { guestChatApi } from '@/api/chat'
import { message } from 'ant-design-vue'
export const useGuestChatStore = defineStore('guestChat', () => {
const conversations = ref([])
const currentConversation = ref(null)
const messages = ref([])
const loading = ref(false)
const typing = ref(false)
const guestUserInfo = ref(null)
// 计算属性
const hasConversations = computed(() => conversations.value.length > 0)
const currentConversationId = computed(() => currentConversation.value?.conversationId)
// 获取或创建访客用户信息
const getOrCreateGuestUser = async () => {
try {
const response = await guestChatApi.getGuestUserInfo()
if (response.code === 200) {
guestUserInfo.value = response.data
return response.data
}
throw new Error(response.message || '获取访客用户信息失败')
} catch (error) {
console.error('获取访客用户信息失败:', error)
// 不显示错误消息,因为这是自动调用的
// message.error('获取访客用户信息失败')
// 创建一个默认的访客用户信息
const defaultGuestUser = {
id: `guest_${Date.now()}`,
name: '访客用户',
isGuest: true,
createTime: new Date().toISOString()
}
guestUserInfo.value = defaultGuestUser
return defaultGuestUser
}
}
// 获取访客会话列表
const fetchConversations = async () => {
try {
const response = await guestChatApi.getGuestConversations()
if (response.code === 200) {
conversations.value = response.data || []
}
} catch (error) {
console.error('获取会话列表失败:', error)
// 不显示错误消息,因为这是自动调用的
// message.error('获取会话列表失败')
conversations.value = []
}
}
// 发送访客聊天消息
const sendMessage = async (content, title = null) => {
try {
typing.value = true
// 添加用户消息到界面
const userMessage = {
id: `user_${Date.now()}`,
content,
sender: 'user',
timestamp: new Date(),
type: 'text'
}
messages.value.push(userMessage)
console.log('添加用户消息:', userMessage)
// 发送到后端
const requestData = {
message: content,
title: title || (currentConversation.value ? null : `对话 ${new Date().toLocaleString()}`),
messageType: 'text'
}
console.log('发送访客聊天请求:', requestData)
const response = await guestChatApi.guestChat(requestData)
console.log('访客聊天响应:', response)
if (response.code === 200) {
const data = response.data
// 如果是新会话,更新当前会话信息
if (data.isNewConversation || !currentConversation.value) {
const newConversation = {
conversationId: data.conversationId,
userId: data.guestUserId,
title: data.conversationTitle || title || `对话 ${new Date().toLocaleString()}`,
type: 'guest_chat',
status: data.conversationStatus || 'active',
createTime: data.timestamp || new Date().toISOString(),
updateTime: data.timestamp || new Date().toISOString(),
messageCount: 2
}
currentConversation.value = newConversation
// 添加到会话列表(如果不存在)
const existingIndex = conversations.value.findIndex(c => c.conversationId === newConversation.conversationId)
if (existingIndex === -1) {
conversations.value.unshift(newConversation)
}
}
// 更新用户消息ID
if (data.userMessageId) {
userMessage.id = data.userMessageId
}
// 处理AI回复 - 支持多条消息
if (data.multipleMessages && data.messageCount > 1) {
// 多条消息的情况 - 从数据库获取最新消息
console.log('检测到多条消息,从数据库获取最新消息')
if (currentConversation.value) {
await fetchMessages(currentConversation.value.conversationId)
}
} else {
// 单条消息的情况 - 直接添加到界面
const aiMessage = {
id: data.aiMessageId || `ai_${Date.now()}`,
content: data.aiReply,
sender: 'assistant',
timestamp: data.timestamp ? new Date(data.timestamp) : new Date(),
type: 'text',
emotionAnalysis: data.emotionAnalysis
}
messages.value.push(aiMessage)
console.log('添加AI消息:', aiMessage)
}
// 更新会话的最后更新时间和消息数量
if (currentConversation.value) {
currentConversation.value.updateTime = new Date().toISOString()
currentConversation.value.messageCount = (currentConversation.value.messageCount || 0) + 2
}
return aiMessage
}
throw new Error(response.message || '发送消息失败')
} catch (error) {
console.error('发送消息失败:', error)
message.error(error.message || '发送消息失败')
// 移除失败的用户消息
messages.value = messages.value.filter(msg => msg.id !== `user_${Date.now()}`)
throw error
} finally {
typing.value = false
}
}
// 获取会话消息
const fetchMessages = async (conversationId) => {
try {
loading.value = true
const response = await guestChatApi.getGuestConversationMessages(conversationId)
if (response.code === 200) {
const rawMessages = response.data || []
// 转换消息格式以适配前端显示
messages.value = rawMessages.map(msg => ({
id: msg.messageId || msg.id,
content: msg.content,
sender: msg.sender === 'user' ? 'user' : 'assistant',
timestamp: new Date(msg.timestamp),
type: msg.type || 'text',
emotionAnalysis: msg.emotionAnalysis ? {
emotionType: msg.emotionType,
emotionScore: msg.emotionScore,
emotionConfidence: msg.emotionConfidence
} : null
}))
console.log('获取到消息:', messages.value.length, '条')
}
} catch (error) {
console.error('获取消息失败:', error)
message.error('获取消息失败')
} finally {
loading.value = false
}
}
// 切换会话
const switchConversation = async (conversation) => {
currentConversation.value = conversation
await fetchMessages(conversation.conversationId)
}
// 清空当前会话
const clearCurrentConversation = () => {
currentConversation.value = null
messages.value = []
}
// 结束会话
const endConversation = async (conversationId) => {
try {
await guestChatApi.endGuestConversation(conversationId)
// 更新会话状态
const conversation = conversations.value.find(c => c.conversationId === conversationId)
if (conversation) {
conversation.status = 'ended'
}
if (currentConversation.value?.conversationId === conversationId) {
clearCurrentConversation()
}
message.success('会话已结束')
} catch (error) {
console.error('结束会话失败:', error)
message.error('结束会话失败')
}
}
// 创建新会话(访客模式下通过发送第一条消息自动创建)
const createNewConversation = () => {
clearCurrentConversation()
return Promise.resolve()
}
// 初始化访客聊天
const initGuestChat = async () => {
try {
await getOrCreateGuestUser()
await fetchConversations()
console.log('访客聊天初始化成功')
} catch (error) {
console.error('初始化访客聊天失败:', error)
// 确保有基本的状态
if (!guestUserInfo.value) {
guestUserInfo.value = {
id: `guest_${Date.now()}`,
name: '访客用户',
isGuest: true,
createTime: new Date().toISOString()
}
}
if (!conversations.value) {
conversations.value = []
}
}
}
return {
conversations,
currentConversation,
messages,
loading,
typing,
guestUserInfo,
hasConversations,
currentConversationId,
getOrCreateGuestUser,
fetchConversations,
sendMessage,
fetchMessages,
switchConversation,
clearCurrentConversation,
endConversation,
createNewConversation,
initGuestChat
}
})
+103
View File
@@ -0,0 +1,103 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const userInfo = ref({
id: '',
name: '',
avatar: ''
})
const isLoggedIn = ref(false)
// 初始化用户信息
const initUser = () => {
try {
// 从localStorage获取用户信息
const savedUser = localStorage.getItem('emotion_museum_user')
if (savedUser) {
try {
const user = JSON.parse(savedUser)
userInfo.value = user
// 只有非访客用户才算真正登录
isLoggedIn.value = !user.isGuest
console.log('用户信息已加载:', user.name || '访客用户')
} catch (parseError) {
console.warn('解析用户信息失败,使用访客模式:', parseError)
// 清除无效数据
localStorage.removeItem('emotion_museum_user')
setGuestMode()
}
} else {
// 访客模式 - 不设置用户信息,保持未登录状态
setGuestMode()
}
} catch (error) {
console.error('初始化用户信息失败:', error)
setGuestMode()
}
}
// 设置访客模式
const setGuestMode = () => {
userInfo.value = {
id: `guest_${Date.now()}`,
name: '访客用户',
avatar: '',
isGuest: true
}
isLoggedIn.value = false
console.log('已切换到访客模式')
}
// 设置用户信息
const setUser = (user) => {
userInfo.value = {
...user,
isGuest: false
}
isLoggedIn.value = true
localStorage.setItem('emotion_museum_user', JSON.stringify(userInfo.value))
}
// 清除用户信息
const clearUser = () => {
userInfo.value = {
id: '',
name: '',
avatar: ''
}
isLoggedIn.value = false
localStorage.removeItem('emotion_museum_user')
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
}
// 登出
const logout = async () => {
try {
// 如果是登录用户,调用后端登出接口
if (isLoggedIn.value && userInfo.value.id) {
const { userApi } = await import('@/api/user')
await userApi.logout(userInfo.value.id)
}
} catch (error) {
console.warn('登出接口调用失败:', error)
} finally {
// 清除本地状态
clearUser()
// 切换到访客模式
setGuestMode()
}
}
return {
userInfo,
isLoggedIn,
initUser,
setUser,
clearUser,
setGuestMode,
logout
}
})
+269
View File
@@ -0,0 +1,269 @@
// 全局样式变量
:root {
// 主题色彩
--primary-color: #667eea;
--primary-light: #8fa4f3;
--primary-dark: #4c63d2;
--secondary-color: #764ba2;
--accent-color: #f093fb;
// 渐变色
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-secondary: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-success: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
--gradient-warning: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
// 文字颜色
--text-primary: #2c3e50;
--text-secondary: #7f8c8d;
--text-light: #bdc3c7;
--text-white: #ffffff;
// 背景色
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-dark: #2c3e50;
--bg-overlay: rgba(0, 0, 0, 0.5);
// 边框和阴影
--border-color: #e9ecef;
--border-radius: 12px;
--border-radius-small: 8px;
--border-radius-large: 16px;
--box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
--box-shadow-hover: 0 8px 30px rgba(0, 0, 0, 0.15);
// 间距
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-xxl: 48px;
}
// 重置样式
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// 通用工具类
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
display: flex;
flex-direction: column;
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.w-full {
width: 100%;
}
.h-full {
height: 100%;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-auto {
overflow: auto;
}
// 渐变文字
.gradient-text {
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 600;
}
// 玻璃态效果
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--border-radius);
}
// 卡片样式
.card {
background: var(--bg-primary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
padding: var(--spacing-lg);
transition: all 0.3s ease;
&:hover {
box-shadow: var(--box-shadow-hover);
transform: translateY(-2px);
}
}
// 按钮样式增强
.ant-btn {
border-radius: var(--border-radius-small);
font-weight: 500;
transition: all 0.3s ease;
&.gradient-btn {
background: var(--gradient-primary);
border: none;
color: white;
&:hover {
background: var(--gradient-primary);
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
}
}
// 输入框样式增强
.ant-input {
border-radius: var(--border-radius-small);
border: 1px solid var(--border-color);
transition: all 0.3s ease;
&:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
}
// 消息气泡样式
.message-bubble {
max-width: 70%;
padding: var(--spacing-md);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-md);
word-wrap: break-word;
&.user {
background: var(--gradient-primary);
color: white;
margin-left: auto;
border-bottom-right-radius: var(--spacing-xs);
}
&.assistant {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-bottom-left-radius: var(--spacing-xs);
}
}
// 响应式设计
@media (max-width: 768px) {
.message-bubble {
max-width: 85%;
}
.card {
padding: var(--spacing-md);
}
}
// 动画类
.bounce-in {
animation: bounceIn 0.6s ease;
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
opacity: 1;
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.fade-in-up {
animation: fadeInUp 0.6s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 加载动画
.loading-dots {
display: inline-block;
&::after {
content: '';
animation: dots 1.5s steps(5, end) infinite;
}
}
@keyframes dots {
0%, 20% {
color: rgba(0, 0, 0, 0);
text-shadow: 0.25em 0 0 rgba(0, 0, 0, 0), 0.5em 0 0 rgba(0, 0, 0, 0);
}
40% {
color: black;
text-shadow: 0.25em 0 0 rgba(0, 0, 0, 0), 0.5em 0 0 rgba(0, 0, 0, 0);
}
60% {
text-shadow: 0.25em 0 0 black, 0.5em 0 0 rgba(0, 0, 0, 0);
}
80%, 100% {
text-shadow: 0.25em 0 0 black, 0.5em 0 0 black;
}
}
+140
View File
@@ -0,0 +1,140 @@
import { userApi } from '@/api/user'
import { useUserStore } from '@/stores/user'
/**
* 认证工具类
*/
export class AuthUtils {
static TOKEN_KEY = 'token'
static REFRESH_TOKEN_KEY = 'refreshToken'
static USER_KEY = 'emotion_museum_user'
/**
* 获取token
*/
static getToken() {
return localStorage.getItem(this.TOKEN_KEY)
}
/**
* 获取刷新token
*/
static getRefreshToken() {
return localStorage.getItem(this.REFRESH_TOKEN_KEY)
}
/**
* 设置token
*/
static setToken(token, refreshToken) {
localStorage.setItem(this.TOKEN_KEY, token)
if (refreshToken) {
localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken)
}
}
/**
* 清除token
*/
static clearTokens() {
localStorage.removeItem(this.TOKEN_KEY)
localStorage.removeItem(this.REFRESH_TOKEN_KEY)
localStorage.removeItem(this.USER_KEY)
}
/**
* 检查token是否存在
*/
static hasToken() {
return !!this.getToken()
}
/**
* 检查token是否即将过期(提前5分钟刷新)
*/
static isTokenExpiringSoon(token) {
if (!token) return true
try {
const payload = JSON.parse(atob(token.split('.')[1]))
const exp = payload.exp * 1000 // 转换为毫秒
const now = Date.now()
const fiveMinutes = 5 * 60 * 1000
return (exp - now) < fiveMinutes
} catch (error) {
console.warn('解析token失败:', error)
return true
}
}
/**
* 刷新token
*/
static async refreshToken() {
const refreshToken = this.getRefreshToken()
if (!refreshToken) {
throw new Error('没有刷新token')
}
try {
const response = await userApi.refreshToken(refreshToken)
if (response.success) {
const { accessToken, refreshToken: newRefreshToken } = response.data
this.setToken(accessToken, newRefreshToken)
return accessToken
} else {
throw new Error(response.message || '刷新token失败')
}
} catch (error) {
// 刷新失败,清除所有token
this.clearTokens()
throw error
}
}
/**
* 自动刷新token(如果需要)
*/
static async autoRefreshToken() {
const token = this.getToken()
if (!token) return null
if (this.isTokenExpiringSoon(token)) {
try {
return await this.refreshToken()
} catch (error) {
console.warn('自动刷新token失败:', error)
// 跳转到登录页
const userStore = useUserStore()
userStore.clearUser()
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
return null
}
}
return token
}
/**
* 登出
*/
static async logout() {
const userStore = useUserStore()
await userStore.logout()
}
}
/**
* 设置token自动刷新定时器
*/
export function setupTokenRefreshTimer() {
// 每5分钟检查一次token是否需要刷新
setInterval(async () => {
if (AuthUtils.hasToken()) {
await AuthUtils.autoRefreshToken()
}
}, 5 * 60 * 1000)
}
+106
View File
@@ -0,0 +1,106 @@
/**
* 环境配置使用示例
*
* 这个文件展示了如何在项目中使用环境变量配置
*/
import { ENV_CONFIG, isDev, isTest, isProd, debugLog, getApiUrl, printEnvInfo } from '@/config/env'
// 示例1: 基础环境判断
export const exampleEnvironmentCheck = () => {
if (isDev()) {
console.log('当前是开发环境')
// 开发环境特有逻辑
} else if (isTest()) {
console.log('当前是测试环境')
// 测试环境特有逻辑
} else if (isProd()) {
console.log('当前是生产环境')
// 生产环境特有逻辑
}
}
// 示例2: 使用调试日志
export const exampleDebugLog = () => {
debugLog('这条日志只在调试模式下显示')
debugLog('用户操作:', { action: 'click', target: 'button' })
}
// 示例3: 获取API地址
export const exampleApiCall = async () => {
const userApiUrl = getApiUrl('/user/profile')
debugLog('API地址:', userApiUrl)
// 使用fetch或axios调用API
try {
const response = await fetch(userApiUrl)
const data = await response.json()
debugLog('API响应:', data)
return data
} catch (error) {
debugLog('API错误:', error)
throw error
}
}
// 示例4: 根据环境配置不同的行为
export const exampleConditionalBehavior = () => {
// 根据环境显示不同的标题
document.title = ENV_CONFIG.APP_TITLE
// 在开发环境启用额外的调试工具
if (ENV_CONFIG.DEBUG_MODE) {
// 启用Vue DevTools
window.__VUE_DEVTOOLS_GLOBAL_HOOK__ = window.__VUE_DEVTOOLS_GLOBAL_HOOK__ || {}
// 打印环境信息
printEnvInfo()
}
// 根据环境配置不同的错误处理
if (isProd()) {
// 生产环境:静默处理错误,发送到监控系统
window.addEventListener('error', (event) => {
// 发送错误到监控系统
console.error('生产环境错误:', event.error)
})
} else {
// 开发/测试环境:显示详细错误信息
window.addEventListener('error', (event) => {
debugLog('开发环境错误:', event.error)
})
}
}
// 示例5: 环境特定的配置
export const getEnvironmentSpecificConfig = () => {
const config = {
// 基础配置
apiTimeout: ENV_CONFIG.API_TIMEOUT,
debugMode: ENV_CONFIG.DEBUG_MODE,
// 环境特定配置
enableAnalytics: isProd(), // 只在生产环境启用分析
enableMocking: ENV_CONFIG.MOCK_DATA, // 根据环境变量决定是否启用模拟数据
logLevel: isDev() ? 'debug' : isProd() ? 'error' : 'info',
// 功能开关
features: {
newFeature: isDev() || isTest(), // 新功能只在开发和测试环境启用
betaFeature: !isProd(), // Beta功能在非生产环境启用
experimentalFeature: isDev() // 实验性功能只在开发环境启用
}
}
debugLog('环境特定配置:', config)
return config
}
// 导出所有示例函数
export default {
exampleEnvironmentCheck,
exampleDebugLog,
exampleApiCall,
exampleConditionalBehavior,
getEnvironmentSpecificConfig
}
+303
View File
@@ -0,0 +1,303 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
// 配置 dayjs
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
/**
* 格式化时间
* @param {string|Date} time - 时间
* @param {string} format - 格式化模板
* @returns {string} 格式化后的时间字符串
*/
export function formatTime(time, format = 'YYYY-MM-DD HH:mm:ss') {
if (!time) return ''
const now = dayjs()
const target = dayjs(time)
const diffInHours = now.diff(target, 'hour')
const diffInDays = now.diff(target, 'day')
// 如果是今天
if (diffInDays === 0) {
if (diffInHours === 0) {
return target.fromNow() // 几分钟前
} else {
return target.format('HH:mm') // 今天的时间
}
}
// 如果是昨天
if (diffInDays === 1) {
return `昨天 ${target.format('HH:mm')}`
}
// 如果是本周
if (diffInDays < 7) {
return target.format('dddd HH:mm')
}
// 如果是今年
if (target.year() === now.year()) {
return target.format('MM-DD HH:mm')
}
// 其他情况使用完整格式
return target.format(format)
}
/**
* 格式化相对时间
* @param {string|Date} time - 时间
* @returns {string} 相对时间字符串
*/
export function formatRelativeTime(time) {
if (!time) return ''
return dayjs(time).fromNow()
}
/**
* 格式化消息内容
* @param {string} content - 消息内容
* @returns {string} 格式化后的HTML内容
*/
export function formatMessage(content) {
if (!content) return ''
// 转义HTML特殊字符
const escaped = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
// 处理换行
let formatted = escaped.replace(/\n/g, '<br>')
// 处理链接
const urlRegex = /(https?:\/\/[^\s]+)/g
formatted = formatted.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>')
// 处理邮箱
const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g
formatted = formatted.replace(emailRegex, '<a href="mailto:$1">$1</a>')
// 处理电话号码
const phoneRegex = /(\d{3}-\d{4}-\d{4}|\d{11})/g
formatted = formatted.replace(phoneRegex, '<a href="tel:$1">$1</a>')
// 处理表情符号(简单的文本表情)
const emoticons = {
':)': '😊',
':-)': '😊',
':(': '😢',
':-(': '😢',
':D': '😃',
':-D': '😃',
':P': '😛',
':-P': '😛',
';)': '😉',
';-)': '😉',
':o': '😮',
':-o': '😮',
':|': '😐',
':-|': '😐',
'<3': '❤️',
'</3': '💔'
}
Object.entries(emoticons).forEach(([text, emoji]) => {
const regex = new RegExp(escapeRegExp(text), 'g')
formatted = formatted.replace(regex, emoji)
})
return formatted
}
/**
* 转义正则表达式特殊字符
* @param {string} string - 要转义的字符串
* @returns {string} 转义后的字符串
*/
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @returns {string} 格式化后的文件大小
*/
export function formatFileSize(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* 格式化数字
* @param {number} num - 数字
* @param {number} precision - 精度
* @returns {string} 格式化后的数字
*/
export function formatNumber(num, precision = 0) {
if (typeof num !== 'number') return '0'
if (num >= 1000000) {
return (num / 1000000).toFixed(precision) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(precision) + 'K'
} else {
return num.toFixed(precision)
}
}
/**
* 格式化百分比
* @param {number} value - 值
* @param {number} total - 总数
* @param {number} precision - 精度
* @returns {string} 百分比字符串
*/
export function formatPercentage(value, total, precision = 1) {
if (total === 0) return '0%'
return ((value / total) * 100).toFixed(precision) + '%'
}
/**
* 截断文本
* @param {string} text - 文本
* @param {number} maxLength - 最大长度
* @param {string} suffix - 后缀
* @returns {string} 截断后的文本
*/
export function truncateText(text, maxLength = 100, suffix = '...') {
if (!text || text.length <= maxLength) return text
return text.substring(0, maxLength - suffix.length) + suffix
}
/**
* 格式化持续时间
* @param {number} seconds - 秒数
* @returns {string} 格式化后的持续时间
*/
export function formatDuration(seconds) {
if (seconds < 60) {
return `${Math.round(seconds)}`
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.round(seconds % 60)
return remainingSeconds > 0 ? `${minutes}${remainingSeconds}` : `${minutes}分钟`
} else {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
}
}
/**
* 格式化货币
* @param {number} amount - 金额
* @param {string} currency - 货币符号
* @returns {string} 格式化后的货币
*/
export function formatCurrency(amount, currency = '¥') {
if (typeof amount !== 'number') return `${currency}0.00`
return `${currency}${amount.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,')}`
}
/**
* 验证邮箱格式
* @param {string} email - 邮箱地址
* @returns {boolean} 是否有效
*/
export function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
/**
* 验证手机号格式
* @param {string} phone - 手机号
* @returns {boolean} 是否有效
*/
export function isValidPhone(phone) {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
/**
* 生成随机ID
* @param {number} length - 长度
* @returns {string} 随机ID
*/
export function generateId(length = 8) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
/**
* 深拷贝对象
* @param {any} obj - 要拷贝的对象
* @returns {any} 拷贝后的对象
*/
export function deepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj
if (obj instanceof Date) return new Date(obj.getTime())
if (obj instanceof Array) return obj.map(item => deepClone(item))
if (typeof obj === 'object') {
const clonedObj = {}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key])
}
}
return clonedObj
}
}
/**
* 防抖函数
* @param {Function} func - 要防抖的函数
* @param {number} wait - 等待时间
* @returns {Function} 防抖后的函数
*/
export function debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
/**
* 节流函数
* @param {Function} func - 要节流的函数
* @param {number} limit - 限制时间
* @returns {Function} 节流后的函数
*/
export function throttle(func, limit) {
let inThrottle
return function executedFunction(...args) {
if (!inThrottle) {
func.apply(this, args)
inThrottle = true
setTimeout(() => inThrottle = false, limit)
}
}
}
+615
View File
@@ -0,0 +1,615 @@
<template>
<div class="analysis-page">
<!-- 页面头部 -->
<header class="page-header glass">
<div class="header-content">
<div class="page-title">
<router-link to="/" class="back-btn">
<ArrowLeftOutlined />
</router-link>
<h1 class="gradient-text">情绪分析</h1>
<span class="subtitle">深入了解您的情绪状态和心理健康</span>
</div>
</div>
</header>
<!-- 主要内容 -->
<main class="page-content">
<div class="content-container">
<!-- 快速分析 -->
<div class="quick-analysis-section">
<div class="section-header">
<h2 class="section-title">快速情绪分析</h2>
<p class="section-description">输入您想要分析的文本获得即时的情绪分析结果</p>
</div>
<div class="analysis-form card">
<a-textarea
v-model:value="analysisText"
placeholder="请输入您想要分析的文本内容,比如今天的心情、遇到的事情等..."
:auto-size="{ minRows: 4, maxRows: 8 }"
class="analysis-input"
/>
<div class="form-actions">
<a-button
type="primary"
class="gradient-btn"
size="large"
@click="analyzeText"
:loading="analyzing"
:disabled="!analysisText.trim()"
>
<SearchOutlined />
开始分析
</a-button>
<a-button
size="large"
@click="clearAnalysis"
:disabled="!analysisText.trim()"
>
<ClearOutlined />
清空
</a-button>
</div>
</div>
<!-- 分析结果 -->
<div class="analysis-result card" v-if="currentAnalysis">
<div class="result-header">
<h3 class="result-title">
<HeartOutlined />
分析结果
</h3>
<div class="result-time">{{ formatTime(currentAnalysis.analysisTime) }}</div>
</div>
<EmotionAnalysis :analysis="currentAnalysis" />
</div>
</div>
<!-- 历史分析记录 -->
<div class="history-analysis-section">
<div class="section-header">
<h2 class="section-title">历史分析记录</h2>
<p class="section-description">查看您过往的情绪分析记录和趋势</p>
</div>
<div class="analysis-history card">
<a-spin :spinning="loadingHistory">
<div class="history-list" v-if="analysisHistory.length > 0">
<div
class="history-item"
v-for="analysis in analysisHistory"
:key="analysis.id"
@click="viewAnalysis(analysis)"
>
<div class="item-header">
<div class="item-emotion">
<a-tag
:color="getEmotionColor(analysis.primaryEmotion)"
class="emotion-tag"
>
{{ getEmotionText(analysis.primaryEmotion) }}
</a-tag>
<span class="emotion-intensity" v-if="analysis.intensity">
{{ Math.round(analysis.intensity * 100) }}%
</span>
</div>
<div class="item-time">{{ formatTime(analysis.analysisTime) }}</div>
</div>
<div class="item-content">
<div class="item-text">{{ analysis.text }}</div>
<div class="item-polarity">
<a-tag
:color="getPolarityColor(analysis.polarity)"
size="small"
>
{{ getPolarityText(analysis.polarity) }}
</a-tag>
</div>
</div>
</div>
</div>
<div class="empty-history" v-else>
<BarChartOutlined class="empty-icon" />
<p>暂无分析记录</p>
<p class="empty-tip">开始您的第一次情绪分析吧</p>
</div>
</a-spin>
</div>
</div>
<!-- 情绪趋势图表 -->
<div class="emotion-trends-section" v-if="analysisHistory.length > 0">
<div class="section-header">
<h2 class="section-title">情绪趋势</h2>
<p class="section-description">查看您的情绪变化趋势和模式</p>
</div>
<div class="trends-chart card">
<EmotionTrends :data="analysisHistory" />
</div>
</div>
</div>
</main>
<!-- 分析详情模态框 -->
<a-modal
v-model:open="showAnalysisDetail"
:title="`情绪分析详情 - ${selectedAnalysis?.primaryEmotion || ''}`"
width="600px"
:footer="null"
>
<div class="analysis-detail" v-if="selectedAnalysis">
<div class="detail-text">
<h4>分析文本</h4>
<p>{{ selectedAnalysis.text }}</p>
</div>
<div class="detail-analysis">
<EmotionAnalysis :analysis="selectedAnalysis" />
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
ArrowLeftOutlined,
SearchOutlined,
ClearOutlined,
HeartOutlined,
BarChartOutlined
} from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user'
import { chatApi } from '@/api/chat'
import { formatTime } from '@/utils/format'
import EmotionAnalysis from '@/components/EmotionAnalysis.vue'
import EmotionTrends from '@/components/EmotionTrends.vue'
const router = useRouter()
const userStore = useUserStore()
// 响应式数据
const analysisText = ref('')
const analyzing = ref(false)
const currentAnalysis = ref(null)
const analysisHistory = ref([])
const loadingHistory = ref(false)
const showAnalysisDetail = ref(false)
const selectedAnalysis = ref(null)
// 情绪映射
const emotionMap = {
joy: '喜悦',
sadness: '悲伤',
anger: '愤怒',
fear: '恐惧',
surprise: '惊讶',
disgust: '厌恶',
trust: '信任',
anticipation: '期待',
anxiety: '焦虑',
depression: '抑郁',
excitement: '兴奋',
calm: '平静',
stress: '压力',
happiness: '快乐',
worry: '担忧',
relief: '放松',
frustration: '沮丧',
hope: '希望',
love: '爱',
hate: '恨'
}
const polarityMap = {
positive: '积极',
negative: '消极',
neutral: '中性'
}
// 方法
const getEmotionText = (emotion) => {
return emotionMap[emotion] || emotion
}
const getPolarityText = (polarity) => {
return polarityMap[polarity] || polarity
}
const getEmotionColor = (emotion) => {
const colorMap = {
joy: 'gold',
happiness: 'gold',
excitement: 'orange',
love: 'magenta',
trust: 'blue',
hope: 'cyan',
calm: 'green',
relief: 'green',
sadness: 'blue',
depression: 'purple',
worry: 'orange',
anxiety: 'orange',
stress: 'red',
anger: 'red',
frustration: 'red',
hate: 'red',
fear: 'volcano',
surprise: 'lime',
anticipation: 'geekblue',
disgust: 'default'
}
return colorMap[emotion] || 'default'
}
const getPolarityColor = (polarity) => {
const colorMap = {
positive: 'success',
negative: 'error',
neutral: 'default'
}
return colorMap[polarity] || 'default'
}
const analyzeText = async () => {
if (!analysisText.value.trim()) return
try {
analyzing.value = true
const response = await chatApi.analyzeEmotion({
userId: userStore.userInfo.id,
text: analysisText.value.trim(),
analysisType: 'detailed'
})
if (response.success) {
currentAnalysis.value = {
...response.data,
analysisTime: new Date(),
text: analysisText.value.trim()
}
// 添加到历史记录
analysisHistory.value.unshift(currentAnalysis.value)
message.success('分析完成')
}
} catch (error) {
console.error('情绪分析失败:', error)
message.error('分析失败,请重试')
} finally {
analyzing.value = false
}
}
const clearAnalysis = () => {
analysisText.value = ''
currentAnalysis.value = null
}
const viewAnalysis = (analysis) => {
selectedAnalysis.value = analysis
showAnalysisDetail.value = true
}
const loadAnalysisHistory = async () => {
try {
loadingHistory.value = true
// 这里应该调用获取历史分析记录的API
// 暂时使用模拟数据
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟历史数据
analysisHistory.value = [
{
id: '1',
text: '今天工作很顺利,心情不错',
primaryEmotion: 'happiness',
intensity: 0.8,
polarity: 'positive',
analysisTime: new Date(Date.now() - 86400000),
confidence: 0.9
},
{
id: '2',
text: '有点担心明天的面试',
primaryEmotion: 'anxiety',
intensity: 0.6,
polarity: 'negative',
analysisTime: new Date(Date.now() - 172800000),
confidence: 0.85
}
]
} catch (error) {
console.error('加载历史记录失败:', error)
} finally {
loadingHistory.value = false
}
}
// 组件挂载
onMounted(() => {
loadAnalysisHistory()
})
</script>
<style lang="scss" scoped>
.analysis-page {
min-height: 100vh;
background: var(--gradient-primary);
.page-header {
position: sticky;
top: 0;
z-index: 100;
padding: var(--spacing-lg) 0;
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-lg);
}
.page-title {
display: flex;
align-items: center;
gap: var(--spacing-md);
.back-btn {
color: rgba(255, 255, 255, 0.8);
font-size: 20px;
transition: color 0.3s ease;
&:hover {
color: white;
}
}
h1 {
margin: 0;
font-size: 28px;
}
.subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
margin-left: var(--spacing-sm);
}
}
}
.page-content {
padding: var(--spacing-xl) var(--spacing-lg);
.content-container {
max-width: 1200px;
margin: 0 auto;
}
.quick-analysis-section,
.history-analysis-section,
.emotion-trends-section {
margin-bottom: var(--spacing-xxl);
.section-header {
text-align: center;
margin-bottom: var(--spacing-xl);
color: white;
.section-title {
font-size: 24px;
margin-bottom: var(--spacing-sm);
}
.section-description {
font-size: 16px;
opacity: 0.8;
}
}
}
.analysis-form {
background: rgba(255, 255, 255, 0.95);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-lg);
.analysis-input {
margin-bottom: var(--spacing-lg);
border-radius: var(--border-radius);
&:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
}
.form-actions {
display: flex;
gap: var(--spacing-md);
justify-content: center;
}
}
.analysis-result {
background: rgba(255, 255, 255, 0.95);
padding: var(--spacing-xl);
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
.result-title {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin: 0;
color: var(--primary-color);
}
.result-time {
font-size: 14px;
color: var(--text-secondary);
}
}
}
.analysis-history {
background: rgba(255, 255, 255, 0.95);
padding: var(--spacing-xl);
.history-list {
.history-item {
padding: var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-md);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
&:last-child {
margin-bottom: 0;
}
.item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-sm);
.item-emotion {
display: flex;
align-items: center;
gap: var(--spacing-xs);
.emotion-intensity {
font-size: 12px;
color: var(--text-secondary);
}
}
.item-time {
font-size: 12px;
color: var(--text-secondary);
}
}
.item-content {
.item-text {
font-size: 14px;
color: var(--text-primary);
line-height: 1.5;
margin-bottom: var(--spacing-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-polarity {
display: flex;
justify-content: flex-end;
}
}
}
}
.empty-history {
text-align: center;
padding: var(--spacing-xxl);
color: var(--text-secondary);
.empty-icon {
font-size: 48px;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
.empty-tip {
font-size: 14px;
margin-top: var(--spacing-xs);
opacity: 0.7;
}
}
}
.trends-chart {
background: rgba(255, 255, 255, 0.95);
padding: var(--spacing-xl);
}
}
.analysis-detail {
.detail-text {
margin-bottom: var(--spacing-lg);
h4 {
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
}
p {
color: var(--text-secondary);
line-height: 1.6;
background: var(--bg-secondary);
padding: var(--spacing-md);
border-radius: var(--border-radius);
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.analysis-page {
.page-header {
.header-content {
padding: 0 var(--spacing-md);
}
.page-title {
.subtitle {
display: none;
}
}
}
.page-content {
padding: var(--spacing-lg) var(--spacing-md);
.analysis-form,
.analysis-result,
.analysis-history,
.trends-chart {
padding: var(--spacing-lg);
}
.form-actions {
flex-direction: column;
.ant-btn {
width: 100%;
}
}
}
}
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<template>
<div class="analysis-simple">
<div class="page-header">
<h1>情绪分析</h1>
<a-button @click="goBack">返回首页</a-button>
</div>
<div class="page-content">
<div class="welcome-message">
<h2>情绪分析功能</h2>
<p>这里将提供强大的情绪分析功能帮助您了解自己的情绪状态</p>
<div class="test-buttons">
<a-button type="primary" @click="testFunction">测试按钮</a-button>
<a-button @click="$router.push('/chat')">开始对话</a-button>
<a-button @click="$router.push('/history')">查看历史</a-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goBack = () => {
router.push('/')
}
const testFunction = () => {
alert('情绪分析页面测试按钮工作正常!')
}
</script>
<style lang="scss" scoped>
.analysis-simple {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.1);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
h1 {
color: white;
margin: 0;
}
}
.page-content {
background: rgba(255, 255, 255, 0.95);
padding: 40px;
border-radius: 12px;
text-align: center;
.welcome-message {
h2 {
color: #333;
margin-bottom: 16px;
}
p {
color: #666;
margin-bottom: 32px;
font-size: 16px;
}
.test-buttons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
}
}
}
</style>
+826
View File
@@ -0,0 +1,826 @@
<template>
<div class="chat-container">
<!-- 侧边栏 -->
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }">
<div class="sidebar-header">
<div class="logo" v-if="!sidebarCollapsed">
<h2 class="gradient-text">情绪博物馆</h2>
</div>
<a-button
type="text"
class="collapse-btn"
@click="toggleSidebar"
>
<MenuOutlined v-if="sidebarCollapsed" />
<MenuFoldOutlined v-else />
</a-button>
</div>
<div class="sidebar-content" v-if="!sidebarCollapsed">
<!-- 新建对话按钮 -->
<a-button
type="primary"
class="new-chat-btn gradient-btn"
block
@click="createNewChat"
:loading="activeStore.loading"
>
<PlusOutlined />
新建对话
</a-button>
<!-- 对话列表 -->
<div class="conversations-list">
<div class="list-header">
<span class="list-title">最近对话</span>
<a-button
type="text"
size="small"
@click="refreshConversations"
:loading="activeStore.loading"
>
<ReloadOutlined />
</a-button>
</div>
<div class="conversations" v-if="activeStore.hasConversations">
<div
class="conversation-item"
:class="{ active: conversation.conversationId === activeStore.currentConversationId }"
v-for="conversation in activeStore.conversations"
:key="conversation.conversationId"
@click="switchConversation(conversation)"
>
<div class="conversation-info">
<div class="conversation-title">{{ conversation.title }}</div>
<div class="conversation-time">{{ formatTime(conversation.updateTime) }}</div>
</div>
<a-dropdown :trigger="['click']">
<a-button type="text" size="small" class="more-btn">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="deleteConversation(conversation.conversationId)">
<DeleteOutlined />
删除对话
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<div class="empty-conversations" v-else>
<CommentOutlined class="empty-icon" />
<p>暂无对话记录</p>
</div>
</div>
</div>
<!-- 用户信息 -->
<div class="user-info" v-if="!sidebarCollapsed">
<div class="user-avatar">
<UserOutlined />
</div>
<div class="user-details">
<div class="user-name">{{ userStore.userInfo.name }}</div>
<div class="user-status">在线</div>
</div>
</div>
</aside>
<!-- 主聊天区域 -->
<main class="chat-main">
<!-- 聊天头部 -->
<header class="chat-header" v-if="chatStore.currentConversation">
<div class="chat-info">
<h3 class="chat-title">{{ chatStore.currentConversation.title }}</h3>
<span class="chat-status">{{ getChatStatus() }}</span>
</div>
<div class="chat-actions">
<a-button type="text" @click="showHistory = true">
<HistoryOutlined />
历史记录
</a-button>
<a-button type="text" @click="endCurrentChat">
<PoweroffOutlined />
结束对话
</a-button>
</div>
</header>
<!-- 消息区域 -->
<div class="messages-container" ref="messagesContainer">
<!-- 欢迎界面 -->
<div class="welcome-screen" v-if="!activeStore.currentConversation">
<div class="welcome-content">
<div class="welcome-icon">
<RobotOutlined />
</div>
<h2 class="welcome-title">欢迎使用AI心理健康助手</h2>
<p class="welcome-description">
我是您的专属AI助手可以为您提供情绪支持心理分析和个性化建议
让我们开始一段温暖的对话吧
</p>
<a-button
type="primary"
size="large"
class="gradient-btn"
@click="createNewChat"
>
<MessageOutlined />
开始对话
</a-button>
</div>
</div>
<!-- 消息列表 -->
<div class="messages-list" v-else>
<div
class="message-item"
:class="message.sender"
v-for="message in activeStore.messages"
:key="message.id"
>
<div class="message-avatar">
<UserOutlined v-if="message.sender === 'user'" />
<RobotOutlined v-else />
</div>
<div class="message-content">
<div class="message-bubble">
<div class="message-text" v-html="formatMessage(message.content)"></div>
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
</div>
<!-- 暂时隐藏情绪分析结果 -->
<!-- <div class="emotion-analysis" v-if="message.emotionAnalysis">
<EmotionAnalysis :analysis="message.emotionAnalysis" />
</div> -->
</div>
</div>
<!-- AI正在输入 -->
<div class="message-item assistant" v-if="activeStore.typing">
<div class="message-avatar">
<RobotOutlined />
</div>
<div class="message-content">
<div class="message-bubble typing">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area" v-if="activeStore.currentConversation">
<div class="input-container">
<a-textarea
v-model:value="inputMessage"
:placeholder="inputPlaceholder"
:auto-size="{ minRows: 1, maxRows: 4 }"
@keydown="handleKeyDown"
:disabled="activeStore.typing"
class="message-input"
/>
<div class="input-actions">
<!-- 暂时隐藏情绪分析按钮 -->
<!-- <a-tooltip title="情绪分析">
<a-button
type="text"
:class="{ active: enableEmotionAnalysis }"
@click="enableEmotionAnalysis = !enableEmotionAnalysis"
>
<HeartOutlined />
</a-button>
</a-tooltip> -->
<a-button
type="primary"
class="send-btn gradient-btn"
@click="sendMessage"
:loading="activeStore.typing"
:disabled="!inputMessage.trim()"
>
<SendOutlined />
</a-button>
</div>
</div>
</div>
</main>
<!-- 历史记录抽屉 -->
<a-drawer
v-model:open="showHistory"
title="对话历史"
placement="right"
width="400"
>
<HistoryPanel />
</a-drawer>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
MenuOutlined,
MenuFoldOutlined,
PlusOutlined,
ReloadOutlined,
MoreOutlined,
DeleteOutlined,
UserOutlined,
RobotOutlined,
MessageOutlined,
SendOutlined,
HeartOutlined,
HistoryOutlined,
PoweroffOutlined,
CommentOutlined
} from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user'
import { useChatStore } from '@/stores/chat'
import { useGuestChatStore } from '@/stores/guestChat'
import EmotionAnalysis from '@/components/EmotionAnalysis.vue'
import HistoryPanel from '@/components/HistoryPanel.vue'
import { formatTime, formatMessage } from '@/utils/format'
const router = useRouter()
const userStore = useUserStore()
const chatStore = useChatStore()
const guestChatStore = useGuestChatStore()
// 判断是否为访客模式(没有登录用户)
const isGuestMode = computed(() => !userStore.isLoggedIn)
const activeStore = computed(() => isGuestMode.value ? guestChatStore : chatStore)
// 响应式数据
const sidebarCollapsed = ref(false)
const inputMessage = ref('')
const enableEmotionAnalysis = ref(false) // 暂时禁用情绪分析
const showHistory = ref(false)
const messagesContainer = ref(null)
// 计算属性
const inputPlaceholder = computed(() => {
return activeStore.value.typing ? 'AI正在思考中...' : '输入您想说的话...'
})
// 方法
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
const createNewChat = async () => {
try {
if (isGuestMode.value) {
// 访客模式:清空当前会话,等待用户发送第一条消息
guestChatStore.createNewConversation()
message.success('准备开始新对话')
} else {
// 注册用户模式
await chatStore.createConversation({
userId: userStore.userInfo.id,
title: `对话 ${new Date().toLocaleString()}`,
type: 'emotion_chat',
initialMessage: '您好,我想开始一段新的对话'
})
message.success('新对话创建成功')
}
} catch (error) {
console.error('创建对话失败:', error)
}
}
const refreshConversations = async () => {
try {
if (isGuestMode.value) {
await guestChatStore.fetchConversations()
} else {
await chatStore.fetchConversations(userStore.userInfo.id)
}
} catch (error) {
console.error('刷新对话列表失败:', error)
}
}
const switchConversation = async (conversation) => {
try {
if (isGuestMode.value) {
await guestChatStore.switchConversation(conversation)
} else {
await chatStore.switchConversation(conversation)
}
scrollToBottom()
} catch (error) {
console.error('切换对话失败:', error)
}
}
const deleteConversation = async (conversationId) => {
try {
if (isGuestMode.value) {
await guestChatStore.endConversation(conversationId)
} else {
await chatStore.deleteConversation(conversationId)
}
} catch (error) {
console.error('删除对话失败:', error)
}
}
const sendMessage = async () => {
if (!inputMessage.value.trim()) return
const content = inputMessage.value.trim()
inputMessage.value = ''
try {
if (isGuestMode.value) {
await guestChatStore.sendMessage(content)
} else {
await chatStore.sendMessage(content, enableEmotionAnalysis.value)
}
scrollToBottom()
} catch (error) {
console.error('发送消息失败:', error)
}
}
const handleKeyDown = (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
sendMessage()
}
}
const getChatStatus = () => {
const store = activeStore.value
if (store.typing) return 'AI正在输入...'
if (store.currentConversation?.status === 'active') return '对话中'
return '已结束'
}
const endCurrentChat = async () => {
const store = activeStore.value
if (!store.currentConversation) return
try {
if (isGuestMode.value) {
await guestChatStore.endConversation(store.currentConversation.conversationId)
} else {
await chatStore.endConversation(store.currentConversation.conversationId)
}
message.success('对话已结束')
} catch (error) {
console.error('结束对话失败:', error)
}
}
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
// 监听消息变化,自动滚动到底部
watch(() => activeStore.value.messages.length, () => {
scrollToBottom()
})
// 组件挂载
onMounted(async () => {
// 初始化访客聊天(如果是访客模式)
if (isGuestMode.value) {
await guestChatStore.initGuestChat()
} else {
// 获取对话列表
await refreshConversations()
// 如果没有当前对话,自动创建一个
if (!chatStore.currentConversation && chatStore.conversations.length === 0) {
await createNewChat()
}
}
})
</script>
<style lang="scss" scoped>
.chat-container {
display: flex;
height: 100vh;
background: var(--gradient-primary);
}
.sidebar {
width: 300px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-right: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: column;
transition: all 0.3s ease;
&.collapsed {
width: 60px;
}
.sidebar-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
.logo h2 {
margin: 0;
font-size: 18px;
}
.collapse-btn {
border: none;
box-shadow: none;
}
}
.sidebar-content {
flex: 1;
padding: var(--spacing-lg);
overflow-y: auto;
.new-chat-btn {
margin-bottom: var(--spacing-lg);
height: 40px;
}
.conversations-list {
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md);
.list-title {
font-weight: 600;
color: var(--text-primary);
}
}
.conversations {
.conversation-item {
display: flex;
align-items: center;
padding: var(--spacing-md);
border-radius: var(--border-radius-small);
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: var(--spacing-xs);
&:hover {
background: var(--bg-secondary);
}
&.active {
background: var(--primary-color);
color: white;
.conversation-time {
color: rgba(255, 255, 255, 0.8);
}
}
.conversation-info {
flex: 1;
min-width: 0;
.conversation-title {
font-weight: 500;
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-time {
font-size: 12px;
color: var(--text-secondary);
}
}
.more-btn {
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover .more-btn {
opacity: 1;
}
}
}
.empty-conversations {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
.empty-icon {
font-size: 48px;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
}
}
}
.user-info {
padding: var(--spacing-lg);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: var(--spacing-md);
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--gradient-primary);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
}
.user-details {
.user-name {
font-weight: 500;
margin-bottom: 2px;
}
.user-status {
font-size: 12px;
color: #52c41a;
}
}
}
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.05);
.chat-header {
padding: var(--spacing-lg);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: space-between;
color: white;
.chat-info {
.chat-title {
margin: 0 0 4px 0;
font-size: 18px;
}
.chat-status {
font-size: 12px;
opacity: 0.8;
}
}
.chat-actions {
display: flex;
gap: var(--spacing-sm);
.ant-btn {
color: white;
border-color: rgba(255, 255, 255, 0.3);
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
}
}
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
.welcome-screen {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.welcome-content {
text-align: center;
color: white;
max-width: 500px;
.welcome-icon {
font-size: 80px;
margin-bottom: var(--spacing-lg);
opacity: 0.8;
}
.welcome-title {
font-size: 28px;
margin-bottom: var(--spacing-md);
}
.welcome-description {
font-size: 16px;
line-height: 1.6;
margin-bottom: var(--spacing-xl);
opacity: 0.9;
}
}
}
.messages-list {
.message-item {
display: flex;
margin-bottom: var(--spacing-lg);
&.user {
flex-direction: row-reverse;
.message-content {
align-items: flex-end;
}
.message-bubble {
background: var(--gradient-primary);
color: white;
border-bottom-right-radius: 4px;
}
}
&.assistant {
.message-bubble {
background: white;
border: 1px solid var(--border-color);
border-bottom-left-radius: 4px;
}
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--gradient-primary);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
margin: 0 var(--spacing-md);
flex-shrink: 0;
}
.message-content {
flex: 1;
display: flex;
flex-direction: column;
max-width: 70%;
.message-bubble {
padding: var(--spacing-md);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
&.typing {
padding: var(--spacing-md) var(--spacing-lg);
}
.message-text {
line-height: 1.6;
word-wrap: break-word;
}
.message-time {
font-size: 12px;
opacity: 0.7;
margin-top: var(--spacing-xs);
}
}
.emotion-analysis {
margin-top: var(--spacing-sm);
}
}
}
}
}
.input-area {
padding: var(--spacing-lg);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(255, 255, 255, 0.2);
.input-container {
display: flex;
align-items: flex-end;
gap: var(--spacing-md);
max-width: 1000px;
margin: 0 auto;
.message-input {
flex: 1;
border-radius: var(--border-radius);
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.9);
&:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
}
.input-actions {
display: flex;
align-items: center;
gap: var(--spacing-sm);
.ant-btn {
&.active {
color: var(--primary-color);
background: rgba(102, 126, 234, 0.1);
}
}
.send-btn {
height: 40px;
padding: 0 var(--spacing-lg);
}
}
}
}
}
// 打字动画
.typing-indicator {
display: flex;
gap: 4px;
span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-secondary);
animation: typing 1.4s infinite ease-in-out;
&:nth-child(1) { animation-delay: -0.32s; }
&:nth-child(2) { animation-delay: -0.16s; }
}
}
@keyframes typing {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
// 响应式设计
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
height: 100vh;
z-index: 1000;
transform: translateX(-100%);
&:not(.collapsed) {
transform: translateX(0);
}
}
.chat-main {
width: 100%;
}
}
</style>
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
<template>
<div class="chat-simple">
<div class="chat-header">
<h1>AI对话页面</h1>
<a-button @click="goBack">返回首页</a-button>
</div>
<div class="chat-content">
<div class="welcome-message">
<h2>欢迎来到AI对话</h2>
<p>这是一个简化版的聊天页面用于测试路由功能</p>
<div class="test-buttons">
<a-button type="primary" @click="testFunction">测试按钮</a-button>
<a-button @click="$router.push('/history')">查看历史</a-button>
<a-button @click="$router.push('/analysis')">情绪分析</a-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goBack = () => {
router.push('/')
}
const testFunction = () => {
alert('测试按钮工作正常!')
}
</script>
<style lang="scss" scoped>
.chat-simple {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.1);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
h1 {
color: white;
margin: 0;
}
}
.chat-content {
background: rgba(255, 255, 255, 0.95);
padding: 40px;
border-radius: 12px;
text-align: center;
.welcome-message {
h2 {
color: #333;
margin-bottom: 16px;
}
p {
color: #666;
margin-bottom: 32px;
font-size: 16px;
}
.test-buttons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
}
}
}
</style>
+298
View File
@@ -0,0 +1,298 @@
<template>
<div class="history-page">
<!-- 页面头部 -->
<header class="page-header glass">
<div class="header-content">
<div class="page-title">
<router-link to="/" class="back-btn">
<ArrowLeftOutlined />
</router-link>
<h1 class="gradient-text">对话历史</h1>
<span class="subtitle">查看和管理您的所有对话记录</span>
</div>
<div class="header-actions">
<a-button type="primary" class="gradient-btn" @click="goToChat">
<MessageOutlined />
新建对话
</a-button>
</div>
</div>
</header>
<!-- 主要内容 -->
<main class="page-content">
<div class="content-container">
<!-- 统计卡片 -->
<div class="stats-cards">
<div class="stat-card card">
<div class="stat-icon">
<MessageOutlined />
</div>
<div class="stat-info">
<div class="stat-number">{{ totalConversations }}</div>
<div class="stat-label">总对话数</div>
</div>
</div>
<div class="stat-card card">
<div class="stat-icon">
<CommentOutlined />
</div>
<div class="stat-info">
<div class="stat-number">{{ totalMessages }}</div>
<div class="stat-label">总消息数</div>
</div>
</div>
<div class="stat-card card">
<div class="stat-icon">
<ClockCircleOutlined />
</div>
<div class="stat-info">
<div class="stat-number">{{ activeConversations }}</div>
<div class="stat-label">活跃对话</div>
</div>
</div>
<div class="stat-card card">
<div class="stat-icon">
<CalendarOutlined />
</div>
<div class="stat-info">
<div class="stat-number">{{ todayConversations }}</div>
<div class="stat-label">今日对话</div>
</div>
</div>
</div>
<!-- 历史记录面板 -->
<div class="history-panel-container card">
<HistoryPanel />
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
ArrowLeftOutlined,
MessageOutlined,
CommentOutlined,
ClockCircleOutlined,
CalendarOutlined
} from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user'
import { useChatStore } from '@/stores/chat'
import HistoryPanel from '@/components/HistoryPanel.vue'
const router = useRouter()
const userStore = useUserStore()
const chatStore = useChatStore()
// 计算属性
const totalConversations = computed(() => {
return chatStore.conversations.length
})
const totalMessages = computed(() => {
return chatStore.conversations.reduce((total, conv) => {
return total + (conv.messageCount || 0)
}, 0)
})
const activeConversations = computed(() => {
return chatStore.conversations.filter(conv => conv.status === 'active').length
})
const todayConversations = computed(() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return chatStore.conversations.filter(conv => {
const convDate = new Date(conv.createTime)
return convDate >= today
}).length
})
// 方法
const goToChat = () => {
router.push('/chat')
}
// 组件挂载
onMounted(async () => {
// 获取对话列表
if (chatStore.conversations.length === 0) {
await chatStore.fetchConversations(userStore.userInfo.id)
}
})
</script>
<style lang="scss" scoped>
.history-page {
min-height: 100vh;
background: var(--gradient-primary);
.page-header {
position: sticky;
top: 0;
z-index: 100;
padding: var(--spacing-lg) 0;
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-lg);
display: flex;
align-items: center;
justify-content: space-between;
}
.page-title {
display: flex;
align-items: center;
gap: var(--spacing-md);
.back-btn {
color: rgba(255, 255, 255, 0.8);
font-size: 20px;
transition: color 0.3s ease;
&:hover {
color: white;
}
}
h1 {
margin: 0;
font-size: 28px;
}
.subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
margin-left: var(--spacing-sm);
}
}
.header-actions {
.gradient-btn {
height: 40px;
padding: 0 var(--spacing-lg);
}
}
}
.page-content {
padding: var(--spacing-xl) var(--spacing-lg);
.content-container {
max-width: 1200px;
margin: 0 auto;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
.stat-card {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-lg);
background: rgba(255, 255, 255, 0.95);
.stat-icon {
width: 50px;
height: 50px;
border-radius: 50%;
background: var(--gradient-primary);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
}
.stat-info {
.stat-number {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 2px;
}
.stat-label {
font-size: 14px;
color: var(--text-secondary);
}
}
}
}
.history-panel-container {
background: rgba(255, 255, 255, 0.95);
padding: var(--spacing-xl);
min-height: 600px;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.history-page {
.page-header {
.header-content {
padding: 0 var(--spacing-md);
flex-direction: column;
gap: var(--spacing-md);
align-items: flex-start;
}
.page-title {
.subtitle {
display: none;
}
}
}
.page-content {
padding: var(--spacing-lg) var(--spacing-md);
.stats-cards {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-md);
.stat-card {
padding: var(--spacing-md);
.stat-icon {
width: 40px;
height: 40px;
font-size: 16px;
}
.stat-info {
.stat-number {
font-size: 20px;
}
.stat-label {
font-size: 12px;
}
}
}
}
.history-panel-container {
padding: var(--spacing-lg);
}
}
}
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<template>
<div class="history-simple">
<div class="page-header">
<h1>对话历史</h1>
<a-button @click="goBack">返回首页</a-button>
</div>
<div class="page-content">
<div class="welcome-message">
<h2>对话历史记录</h2>
<p>这里将显示您的所有对话历史记录</p>
<div class="test-buttons">
<a-button type="primary" @click="testFunction">测试按钮</a-button>
<a-button @click="$router.push('/chat')">开始对话</a-button>
<a-button @click="$router.push('/analysis')">情绪分析</a-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goBack = () => {
router.push('/')
}
const testFunction = () => {
alert('历史记录页面测试按钮工作正常!')
}
</script>
<style lang="scss" scoped>
.history-simple {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.1);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
h1 {
color: white;
margin: 0;
}
}
.page-content {
background: rgba(255, 255, 255, 0.95);
padding: 40px;
border-radius: 12px;
text-align: center;
.welcome-message {
h2 {
color: #333;
margin-bottom: 16px;
}
p {
color: #666;
margin-bottom: 32px;
font-size: 16px;
}
.test-buttons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
}
}
}
</style>
+569
View File
@@ -0,0 +1,569 @@
<template>
<div class="home-container">
<!-- 导航栏 -->
<header class="header glass">
<div class="header-content">
<div class="logo">
<h1 class="gradient-text">情绪博物馆</h1>
<span class="subtitle">AI心理健康助手</span>
</div>
<nav class="nav-menu">
<a-button type="text" class="nav-item" @click="$router.push('/chat')">
<MessageOutlined />
AI对话
</a-button>
<a-button type="text" class="nav-item" @click="$router.push('/history')">
<HistoryOutlined />
历史记录
</a-button>
<a-button type="text" class="nav-item" @click="$router.push('/analysis')">
<BarChartOutlined />
情绪分析
</a-button>
<!-- 用户状态区域 -->
<div class="user-area">
<template v-if="userStore.isLoggedIn">
<a-dropdown>
<a-button type="text" class="nav-item user-btn">
<UserOutlined />
{{ userStore.userInfo.username || userStore.userInfo.account }}
</a-button>
<template #overlay>
<a-menu>
<a-menu-item key="profile">
<UserOutlined />
个人资料
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" @click="handleLogout">
<LogoutOutlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<template v-else>
<a-button type="text" class="nav-item" @click="$router.push('/login')">
<LoginOutlined />
登录
</a-button>
</template>
</div>
</nav>
</div>
</header>
<!-- 主要内容 -->
<main class="main-content">
<div class="hero-section">
<div class="hero-content fade-in-up">
<h2 class="hero-title">
欢迎来到情绪博物馆
</h2>
<p class="hero-description">
您的专属AI心理健康助手提供24/7情绪支持心理分析和个性化建议
</p>
<div class="hero-actions">
<a-button
type="primary"
size="large"
class="start-chat-btn"
@click="startChat"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; margin-right: 16px;"
>
<MessageOutlined />
开始对话
</a-button>
<a-button
size="large"
class="learn-more-btn"
@click="scrollToFeatures"
style="background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); color: white;"
>
了解更多
</a-button>
</div>
</div>
<!-- 装饰性元素 -->
<div class="hero-decoration">
<div class="floating-card card bounce-in" style="animation-delay: 0.2s">
<HeartOutlined class="icon" />
<span>情绪识别</span>
</div>
<div class="floating-card card bounce-in" style="animation-delay: 0.4s">
<BulbOutlined class="icon" />
<span>智能建议</span>
</div>
<div class="floating-card card bounce-in" style="animation-delay: 0.6s">
<SafetyOutlined class="icon" />
<span>隐私保护</span>
</div>
</div>
</div>
<!-- 功能特性 -->
<section class="features-section" ref="featuresRef">
<div class="section-header">
<h3 class="section-title gradient-text">核心功能</h3>
<p class="section-description">专业的AI技术贴心的情绪关怀</p>
</div>
<div class="features-grid">
<div class="feature-card card" v-for="feature in features" :key="feature.id">
<div class="feature-icon">
<component :is="feature.icon" />
</div>
<h4 class="feature-title">{{ feature.title }}</h4>
<p class="feature-description">{{ feature.description }}</p>
</div>
</div>
</section>
<!-- 统计数据 -->
<section class="stats-section">
<div class="stats-container glass">
<div class="stat-item" v-for="stat in stats" :key="stat.label">
<div class="stat-number gradient-text">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</section>
<!-- API测试组件 (仅开发环境) -->
<section v-if="showApiTest" class="api-test-section">
<ApiTest />
</section>
</main>
<!-- 页脚 -->
<footer class="footer">
<div class="footer-content">
<p>&copy; 2025 情绪博物馆. 用心守护每一份情绪</p>
</div>
</footer>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
MessageOutlined,
HistoryOutlined,
BarChartOutlined,
HeartOutlined,
BulbOutlined,
SafetyOutlined,
RobotOutlined,
LineChartOutlined,
ClockCircleOutlined,
LockOutlined,
UserOutlined,
LoginOutlined,
LogoutOutlined
} from '@ant-design/icons-vue'
import { ENV_CONFIG } from '@/config/env'
import { useUserStore } from '@/stores/user'
import ApiTest from '@/components/ApiTest.vue'
const router = useRouter()
const userStore = useUserStore()
const featuresRef = ref(null)
// 是否显示API测试组件 (仅开发环境)
const showApiTest = computed(() => ENV_CONFIG.isDevelopment)
// 功能特性数据
const features = ref([
{
id: 1,
icon: RobotOutlined,
title: 'AI智能对话',
description: '基于先进的自然语言处理技术,提供自然流畅的对话体验'
},
{
id: 2,
icon: LineChartOutlined,
title: '情绪分析',
description: '实时分析您的情绪状态,提供专业的心理健康评估'
},
{
id: 3,
icon: ClockCircleOutlined,
title: '24/7支持',
description: '全天候在线服务,随时随地为您提供情绪支持和心理疏导'
},
{
id: 4,
icon: LockOutlined,
title: '隐私保护',
description: '严格保护用户隐私,所有对话内容都经过加密处理'
}
])
// 统计数据
const stats = ref([
{ value: '10,000+', label: '用户信赖' },
{ value: '50,000+', label: '对话次数' },
{ value: '95%', label: '满意度' },
{ value: '24/7', label: '在线服务' }
])
// 开始对话
const startChat = () => {
console.log('开始对话按钮被点击')
router.push('/chat')
}
// 滚动到功能区域
const scrollToFeatures = () => {
featuresRef.value?.scrollIntoView({ behavior: 'smooth' })
}
// 处理登出
const handleLogout = async () => {
try {
await userStore.logout()
message.success('已退出登录')
} catch (error) {
console.error('登出失败:', error)
message.error('登出失败')
}
}
onMounted(() => {
// 页面加载动画
document.body.style.overflow = 'hidden'
setTimeout(() => {
document.body.style.overflow = 'auto'
}, 1000)
})
</script>
<style lang="scss" scoped>
.home-container {
min-height: 100vh;
background: var(--gradient-primary);
position: relative;
overflow-x: hidden;
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
padding: var(--spacing-md) 0;
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-lg);
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
h1 {
font-size: 24px;
margin: 0;
}
.subtitle {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
margin-left: var(--spacing-sm);
}
}
.nav-menu {
display: flex;
align-items: center;
gap: var(--spacing-lg);
.nav-item {
color: rgba(255, 255, 255, 0.9) !important;
border: none !important;
box-shadow: none !important;
background: transparent !important;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius-small);
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: var(--spacing-xs);
&:hover {
background: rgba(255, 255, 255, 0.1) !important;
color: white !important;
}
}
.user-area {
margin-left: var(--spacing-md);
.user-btn {
background: rgba(255, 255, 255, 0.1) !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
&:hover {
background: rgba(255, 255, 255, 0.2) !important;
}
}
}
}
}
.main-content {
padding-top: 80px;
}
.hero-section {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: var(--spacing-xxl) var(--spacing-lg);
.hero-content {
text-align: center;
max-width: 600px;
color: white;
.hero-title {
font-size: 48px;
font-weight: 700;
margin-bottom: var(--spacing-lg);
line-height: 1.2;
}
.hero-description {
font-size: 18px;
margin-bottom: var(--spacing-xxl);
opacity: 0.9;
line-height: 1.6;
}
.hero-actions {
display: flex;
gap: var(--spacing-md);
justify-content: center;
flex-wrap: wrap;
.start-chat-btn {
height: 50px;
padding: 0 var(--spacing-xl);
font-size: 16px;
}
.learn-more-btn {
height: 50px;
padding: 0 var(--spacing-xl);
font-size: 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.5);
}
}
}
}
.hero-decoration {
position: absolute;
top: 50%;
right: 10%;
transform: translateY(-50%);
.floating-card {
position: absolute;
padding: var(--spacing-md);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
display: flex;
align-items: center;
gap: var(--spacing-sm);
white-space: nowrap;
.icon {
font-size: 20px;
}
&:nth-child(1) {
top: -60px;
right: 0;
}
&:nth-child(2) {
top: 20px;
right: -40px;
}
&:nth-child(3) {
top: 100px;
right: 20px;
}
}
}
}
.features-section {
padding: var(--spacing-xxl) var(--spacing-lg);
background: rgba(255, 255, 255, 0.05);
.section-header {
text-align: center;
margin-bottom: var(--spacing-xxl);
color: white;
.section-title {
font-size: 36px;
margin-bottom: var(--spacing-md);
}
.section-description {
font-size: 16px;
opacity: 0.8;
}
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--spacing-xl);
max-width: 1000px;
margin: 0 auto;
.feature-card {
text-align: center;
background: rgba(255, 255, 255, 0.95);
.feature-icon {
font-size: 48px;
color: var(--primary-color);
margin-bottom: var(--spacing-md);
}
.feature-title {
font-size: 20px;
margin-bottom: var(--spacing-md);
color: var(--text-primary);
}
.feature-description {
color: var(--text-secondary);
line-height: 1.6;
}
}
}
}
.stats-section {
padding: var(--spacing-xxl) var(--spacing-lg);
.stats-container {
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-xl);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-xl);
text-align: center;
.stat-item {
.stat-number {
font-size: 36px;
font-weight: 700;
margin-bottom: var(--spacing-sm);
}
.stat-label {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
}
}
}
}
.api-test-section {
padding: var(--spacing-xxl) var(--spacing-lg);
background: rgba(255, 255, 255, 0.05);
:deep(.ant-card) {
background: rgba(255, 255, 255, 0.95);
border: none;
border-radius: var(--border-radius-large);
box-shadow: var(--shadow-large);
}
}
.footer {
padding: var(--spacing-xl) var(--spacing-lg);
background: rgba(0, 0, 0, 0.2);
.footer-content {
text-align: center;
color: rgba(255, 255, 255, 0.7);
}
}
// 响应式设计
@media (max-width: 768px) {
.header {
.header-content {
padding: 0 var(--spacing-md);
}
.nav-menu {
gap: var(--spacing-md);
.nav-item {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 14px;
}
}
}
.hero-section {
.hero-content {
.hero-title {
font-size: 32px;
}
.hero-description {
font-size: 16px;
}
}
.hero-decoration {
display: none;
}
}
.features-section {
.features-grid {
grid-template-columns: 1fr;
gap: var(--spacing-lg);
}
}
.stats-section {
.stats-container {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-lg);
}
}
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div class="home-test">
<h1>情绪博物馆测试页面</h1>
<p>如果您能看到这个页面说明Vue应用正在正常工作</p>
<div class="test-buttons">
<button @click="testAlert" class="test-btn">测试按钮1</button>
<button @click="goToChat" class="test-btn">前往聊天页面</button>
<button @click="goToHistory" class="test-btn">前往历史页面</button>
<button @click="goToAnalysis" class="test-btn">前往分析页面</button>
</div>
<div class="info">
<p>当前时间: {{ currentTime }}</p>
<p>页面加载状态: 正常</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const currentTime = ref('')
const updateTime = () => {
currentTime.value = new Date().toLocaleString()
}
const testAlert = () => {
alert('测试按钮工作正常!Vue应用运行正常!')
}
const goToChat = () => {
router.push('/chat')
}
const goToHistory = () => {
router.push('/history')
}
const goToAnalysis = () => {
router.push('/analysis')
}
onMounted(() => {
updateTime()
setInterval(updateTime, 1000)
console.log('HomeTest页面加载成功')
})
</script>
<style scoped>
.home-test {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px;
text-align: center;
color: white;
}
h1 {
font-size: 2.5rem;
margin-bottom: 20px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
p {
font-size: 1.2rem;
margin-bottom: 30px;
}
.test-buttons {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 40px;
}
.test-btn {
padding: 12px 24px;
font-size: 16px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
color: white;
cursor: pointer;
transition: all 0.3s ease;
}
.test-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-2px);
}
.info {
background: rgba(255, 255, 255, 0.1);
padding: 20px;
border-radius: 12px;
max-width: 400px;
margin: 0 auto;
}
.info p {
margin: 10px 0;
font-size: 1rem;
}
</style>
+537
View File
@@ -0,0 +1,537 @@
<template>
<div class="login-container">
<div class="login-card glass">
<div class="login-header">
<h1 class="gradient-text">情绪博物馆</h1>
<p class="subtitle">AI心理健康助手</p>
</div>
<a-tabs v-model:activeKey="activeTab" centered>
<!-- 登录标签页 -->
<a-tab-pane key="login" tab="登录">
<a-form
:model="loginForm"
:rules="loginRules"
@finish="handleLogin"
layout="vertical"
class="login-form"
>
<a-form-item name="account" label="账号">
<a-input
v-model:value="loginForm.account"
placeholder="请输入账号/邮箱/手机号"
size="large"
:prefix="h(UserOutlined)"
/>
</a-form-item>
<a-form-item name="password" label="密码">
<a-input-password
v-model:value="loginForm.password"
placeholder="请输入密码"
size="large"
:prefix="h(LockOutlined)"
/>
</a-form-item>
<a-form-item name="captcha" label="验证码">
<div class="captcha-wrapper">
<CaptchaInput
v-model="loginForm.captcha"
v-model:captcha-id="loginForm.captchaId"
placeholder="请输入验证码"
size="large"
@enter="handleLogin"
/>
</div>
</a-form-item>
<a-form-item>
<div class="form-options">
<a-checkbox v-model:checked="loginForm.rememberMe">
记住我
</a-checkbox>
<a-button type="link" size="small" @click="showSliderCaptcha = true">
使用滑块验证
</a-button>
</div>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
block
:loading="loginLoading"
class="login-btn"
>
登录
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<!-- 注册标签页 -->
<a-tab-pane key="register" tab="注册">
<a-form
:model="registerForm"
:rules="registerRules"
@finish="handleRegister"
layout="vertical"
class="register-form"
>
<a-form-item name="account" label="账号">
<a-input
v-model:value="registerForm.account"
placeholder="请输入账号"
size="large"
:prefix="h(UserOutlined)"
/>
</a-form-item>
<a-form-item name="email" label="邮箱">
<a-input
v-model:value="registerForm.email"
placeholder="请输入邮箱"
size="large"
:prefix="h(MailOutlined)"
/>
</a-form-item>
<a-form-item name="phone" label="手机号">
<a-input
v-model:value="registerForm.phone"
placeholder="请输入手机号(可选)"
size="large"
:prefix="h(PhoneOutlined)"
/>
</a-form-item>
<a-form-item name="password" label="密码">
<a-input-password
v-model:value="registerForm.password"
placeholder="请输入密码"
size="large"
:prefix="h(LockOutlined)"
/>
</a-form-item>
<a-form-item name="confirmPassword" label="确认密码">
<a-input-password
v-model:value="registerForm.confirmPassword"
placeholder="请再次输入密码"
size="large"
:prefix="h(LockOutlined)"
/>
</a-form-item>
<a-form-item name="username" label="用户名">
<a-input
v-model:value="registerForm.username"
placeholder="请输入用户名"
size="large"
:prefix="h(UserOutlined)"
/>
</a-form-item>
<a-form-item name="captcha" label="验证码">
<div class="captcha-wrapper">
<CaptchaInput
v-model="registerForm.captcha"
v-model:captcha-id="registerForm.captchaId"
placeholder="请输入验证码"
size="large"
@enter="handleRegister"
/>
</div>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
block
:loading="registerLoading"
class="register-btn"
>
注册
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
<!-- 第三方登录 -->
<SocialLogin @success="handleSocialLoginSuccess" @error="handleSocialLoginError" />
<div class="login-footer">
<a-button type="link" @click="goHome">
<ArrowLeftOutlined />
返回首页
</a-button>
</div>
</div>
<!-- 滑块验证码模态框 -->
<a-modal
v-model:open="showSliderCaptcha"
title="滑块验证"
:footer="null"
:width="350"
centered
>
<SliderCaptcha
ref="sliderCaptchaRef"
@success="handleSliderSuccess"
@error="handleSliderError"
/>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, h } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
UserOutlined,
LockOutlined,
MailOutlined,
PhoneOutlined,
ArrowLeftOutlined
} from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user'
import { userApi } from '@/api/user'
import CaptchaInput from '@/components/CaptchaInput.vue'
import SliderCaptcha from '@/components/SliderCaptcha.vue'
import SocialLogin from '@/components/SocialLogin.vue'
const router = useRouter()
const userStore = useUserStore()
// 当前活跃标签页
const activeTab = ref('login')
// 登录表单
const loginForm = reactive({
account: '',
password: '',
rememberMe: false,
captchaId: '',
captcha: ''
})
// 注册表单
const registerForm = reactive({
account: '',
email: '',
phone: '',
password: '',
confirmPassword: '',
username: '',
captchaId: '',
captcha: ''
})
// 加载状态
const loginLoading = ref(false)
const registerLoading = ref(false)
const showSliderCaptcha = ref(false)
// 组件引用
const sliderCaptchaRef = ref(null)
// 登录表单验证规则
const loginRules = {
account: [
{ required: true, message: '请输入账号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
],
captchaId: [
{ required: true, message: '请获取验证码', trigger: 'blur' }
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' }
]
}
// 注册表单验证规则
const registerRules = {
account: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 3, max: 20, message: '账号长度为3-20位', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '账号只能包含字母、数字和下划线', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度为6-20位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (rule, value) => {
if (value !== registerForm.password) {
return Promise.reject('两次密码输入不一致')
}
return Promise.resolve()
},
trigger: 'blur'
}
],
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 10, message: '用户名长度为2-10位', trigger: 'blur' }
],
captchaId: [
{ required: true, message: '请获取验证码', trigger: 'blur' }
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' }
]
}
// 处理登录
const handleLogin = async (values) => {
try {
loginLoading.value = true
const response = await userApi.login(values)
if (response.success) {
const { accessToken, refreshToken, userInfo } = response.data
// 存储token
localStorage.setItem('token', accessToken)
localStorage.setItem('refreshToken', refreshToken)
// 更新用户状态
userStore.setUser(userInfo)
message.success('登录成功')
// 跳转到首页或之前的页面
const redirect = router.currentRoute.value.query.redirect || '/'
router.push(redirect)
} else {
message.error(response.message || '登录失败')
}
} catch (error) {
console.error('登录失败:', error)
message.error('登录失败,请稍后重试')
} finally {
loginLoading.value = false
}
}
// 处理注册
const handleRegister = async (values) => {
try {
registerLoading.value = true
const response = await userApi.register(values)
if (response.success) {
message.success('注册成功,请登录')
activeTab.value = 'login'
// 清空注册表单
Object.keys(registerForm).forEach(key => {
registerForm[key] = ''
})
} else {
message.error(response.message || '注册失败')
}
} catch (error) {
console.error('注册失败:', error)
message.error('注册失败,请稍后重试')
} finally {
registerLoading.value = false
}
}
// 返回首页
const goHome = () => {
router.push('/')
}
// 处理第三方登录成功
const handleSocialLoginSuccess = (userInfo) => {
message.success('登录成功')
router.push('/')
}
// 处理第三方登录失败
const handleSocialLoginError = (error) => {
console.error('第三方登录失败:', error)
}
// 处理滑块验证成功
const handleSliderSuccess = (captchaId) => {
showSliderCaptcha.value = false
// 可以在这里设置滑块验证码ID到表单中
if (activeTab.value === 'login') {
loginForm.captchaId = captchaId
loginForm.captcha = 'slider_verified'
} else {
registerForm.captchaId = captchaId
registerForm.captcha = 'slider_verified'
}
message.success('滑块验证成功')
}
// 处理滑块验证失败
const handleSliderError = () => {
message.error('滑块验证失败,请重试')
}
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 40px;
border-radius: 20px;
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 30px;
h1 {
font-size: 28px;
font-weight: bold;
margin-bottom: 8px;
}
.subtitle {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
margin: 0;
}
}
.gradient-text {
background: linear-gradient(45deg, #fff, #f0f0f0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-form,
.register-form {
:deep(.ant-form-item-label > label) {
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
:deep(.ant-input),
:deep(.ant-input-password .ant-input) {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
&::placeholder {
color: rgba(255, 255, 255, 0.6);
}
&:hover,
&:focus {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.5);
}
}
:deep(.ant-input-prefix) {
color: rgba(255, 255, 255, 0.6);
}
:deep(.ant-checkbox-wrapper) {
color: rgba(255, 255, 255, 0.9);
}
}
.login-btn,
.register-btn {
background: linear-gradient(45deg, #667eea, #764ba2);
border: none;
height: 45px;
font-size: 16px;
font-weight: 500;
&:hover {
background: linear-gradient(45deg, #5a6fd8, #6a4190);
}
}
.captcha-wrapper {
width: 100%;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.ant-checkbox-wrapper {
color: rgba(255, 255, 255, 0.8);
}
.ant-btn-link {
color: rgba(255, 255, 255, 0.7);
padding: 0;
&:hover {
color: white;
}
}
}
.login-footer {
text-align: center;
margin-top: 20px;
:deep(.ant-btn-link) {
color: rgba(255, 255, 255, 0.8);
&:hover {
color: white;
}
}
}
:deep(.ant-tabs-tab) {
color: rgba(255, 255, 255, 0.7) !important;
&.ant-tabs-tab-active {
color: white !important;
}
}
:deep(.ant-tabs-ink-bar) {
background: white;
}
</style>