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