feat: 完成Nacos配置优化和WebSocket集成
主要更新: 1. 统一所有微服务端口配置(19000-19008) 2. 为所有服务创建本地/测试/生产三套环境配置 3. 配置Nacos认证密码(本地:Peanut2817*#, 测试/生产:EmotionMuseum2025) 4. 优化网关路由配置,支持负载均衡和WebSocket 5. 新增emotion-websocket模块,支持实时聊天 6. 前端集成WebSocket,替代HTTP轮询 7. 添加配置验证和管理工具脚本 技术特性: - 完整的环境隔离和服务发现 - WebSocket实时通信支持 - 负载均衡路由配置 - 跨域和安全配置 - 自动重连和心跳检测
This commit is contained in:
@@ -1,375 +0,0 @@
|
||||
<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>
|
||||
@@ -1,178 +0,0 @@
|
||||
<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>
|
||||
@@ -1,357 +0,0 @@
|
||||
<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>
|
||||
@@ -1,381 +0,0 @@
|
||||
<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>
|
||||
@@ -1,296 +0,0 @@
|
||||
<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>
|
||||
@@ -1,665 +0,0 @@
|
||||
<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>
|
||||
@@ -1,127 +0,0 @@
|
||||
<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>
|
||||
@@ -1,492 +0,0 @@
|
||||
<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>
|
||||
@@ -1,350 +0,0 @@
|
||||
<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>
|
||||
@@ -1,261 +0,0 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user