feat: 项目初始化及当前全部内容提交
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# 基础环境变量配置
|
||||
VITE_APP_TITLE=情绪博物馆
|
||||
VITE_APP_VERSION=1.0.0
|
||||
|
||||
# API配置
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_API_TIMEOUT=30000
|
||||
|
||||
# 开发环境配置
|
||||
VITE_APP_ENV=development
|
||||
@@ -0,0 +1,12 @@
|
||||
# 开发环境配置
|
||||
VITE_APP_ENV=development
|
||||
VITE_APP_TITLE=情绪博物馆(开发环境)
|
||||
|
||||
# 开发环境API配置
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_API_TARGET=http://localhost:9000
|
||||
VITE_API_TIMEOUT=30000
|
||||
|
||||
# 开发环境特殊配置
|
||||
VITE_DEBUG_MODE=true
|
||||
VITE_MOCK_DATA=false
|
||||
@@ -0,0 +1,13 @@
|
||||
# Docker环境配置
|
||||
VITE_APP_TITLE=情绪博物馆
|
||||
VITE_APP_VERSION=1.0.0
|
||||
VITE_APP_ENV=docker
|
||||
|
||||
# API配置
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_API_TARGET=http://gateway:9000
|
||||
VITE_API_TIMEOUT=30000
|
||||
|
||||
# 功能开关
|
||||
VITE_DEBUG_MODE=false
|
||||
VITE_MOCK_DATA=false
|
||||
@@ -0,0 +1,17 @@
|
||||
# 生产环境配置
|
||||
VITE_APP_TITLE=情绪博物馆
|
||||
VITE_APP_VERSION=1.0.0
|
||||
VITE_APP_ENV=production
|
||||
|
||||
# API配置 - 生产环境
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_API_TARGET=http://47.111.10.27:9000
|
||||
VITE_API_TIMEOUT=30000
|
||||
|
||||
# 功能开关
|
||||
VITE_DEBUG_MODE=false
|
||||
VITE_MOCK_DATA=false
|
||||
|
||||
# 部署配置
|
||||
VITE_PUBLIC_PATH=/emotion-museum/
|
||||
VITE_OUTPUT_DIR=dist
|
||||
@@ -0,0 +1,12 @@
|
||||
# 测试环境配置
|
||||
VITE_APP_ENV=test
|
||||
VITE_APP_TITLE=情绪博物馆(测试环境)
|
||||
|
||||
# 测试环境API配置
|
||||
VITE_API_BASE_URL=https://test-api.emotion-museum.com/api
|
||||
VITE_API_TARGET=https://test-api.emotion-museum.com
|
||||
VITE_API_TIMEOUT=30000
|
||||
|
||||
# 测试环境特殊配置
|
||||
VITE_DEBUG_MODE=true
|
||||
VITE_MOCK_DATA=false
|
||||
@@ -0,0 +1,41 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnpm
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Environment variables
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
@@ -0,0 +1,55 @@
|
||||
# 前端应用Dockerfile
|
||||
# 构建阶段
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置npm镜像源
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 复制package文件
|
||||
COPY package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm ci --only=production
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN npm run build
|
||||
|
||||
# 生产阶段
|
||||
FROM nginx:alpine
|
||||
|
||||
# 安装必要工具
|
||||
RUN apk add --no-cache curl tzdata && \
|
||||
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
echo "Asia/Shanghai" > /etc/timezone
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# 复制nginx配置
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 创建nginx用户
|
||||
RUN addgroup -g 101 -S nginx && \
|
||||
adduser -S -D -H -u 101 -h /var/cache/nginx -s /sbin/nologin -G nginx -g nginx nginx
|
||||
|
||||
# 设置权限
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||
chown -R nginx:nginx /var/cache/nginx && \
|
||||
chown -R nginx:nginx /var/log/nginx && \
|
||||
chown -R nginx:nginx /etc/nginx/conf.d
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:80/ || exit 1
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 80
|
||||
|
||||
# 启动nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,140 @@
|
||||
# 环境变量配置说明
|
||||
|
||||
## 概述
|
||||
|
||||
本项目使用 Vite 的环境变量功能来管理不同环境(开发、测试、生产)的配置。通过环境变量,可以灵活控制API地址、调试模式、功能开关等。
|
||||
|
||||
## 环境变量文件
|
||||
|
||||
### 1. `.env` - 基础配置
|
||||
所有环境共享的基础配置
|
||||
```bash
|
||||
VITE_APP_TITLE=情绪博物馆
|
||||
VITE_APP_VERSION=1.0.0
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_API_TIMEOUT=30000
|
||||
VITE_APP_ENV=development
|
||||
```
|
||||
|
||||
### 2. `.env.development` - 开发环境
|
||||
```bash
|
||||
VITE_APP_ENV=development
|
||||
VITE_APP_TITLE=情绪博物馆(开发环境)
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_API_TARGET=http://localhost:9001
|
||||
VITE_API_TIMEOUT=30000
|
||||
VITE_DEBUG_MODE=true
|
||||
VITE_MOCK_DATA=false
|
||||
```
|
||||
|
||||
### 3. `.env.test` - 测试环境
|
||||
```bash
|
||||
VITE_APP_ENV=test
|
||||
VITE_APP_TITLE=情绪博物馆(测试环境)
|
||||
VITE_API_BASE_URL=https://test-api.emotion-museum.com/api
|
||||
VITE_API_TARGET=https://test-api.emotion-museum.com
|
||||
VITE_API_TIMEOUT=30000
|
||||
VITE_DEBUG_MODE=true
|
||||
VITE_MOCK_DATA=false
|
||||
```
|
||||
|
||||
### 4. `.env.production` - 生产环境
|
||||
```bash
|
||||
VITE_APP_ENV=production
|
||||
VITE_APP_TITLE=情绪博物馆
|
||||
VITE_API_BASE_URL=https://api.emotion-museum.com/api
|
||||
VITE_API_TARGET=https://api.emotion-museum.com
|
||||
VITE_API_TIMEOUT=30000
|
||||
VITE_DEBUG_MODE=false
|
||||
VITE_MOCK_DATA=false
|
||||
```
|
||||
|
||||
## 环境变量说明
|
||||
|
||||
| 变量名 | 说明 | 示例值 |
|
||||
|--------|------|--------|
|
||||
| `VITE_APP_TITLE` | 应用标题 | `情绪博物馆` |
|
||||
| `VITE_APP_VERSION` | 应用版本 | `1.0.0` |
|
||||
| `VITE_APP_ENV` | 环境标识 | `development/test/production` |
|
||||
| `VITE_API_BASE_URL` | API基础路径 | `/api` 或完整URL |
|
||||
| `VITE_API_TARGET` | API目标服务器 | `http://localhost:9001` |
|
||||
| `VITE_API_TIMEOUT` | API超时时间(ms) | `30000` |
|
||||
| `VITE_DEBUG_MODE` | 调试模式 | `true/false` |
|
||||
| `VITE_MOCK_DATA` | 模拟数据开关 | `true/false` |
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 在代码中使用环境配置
|
||||
```javascript
|
||||
import { ENV_CONFIG, isDev, isTest, isProd, debugLog } from '@/config/env'
|
||||
|
||||
// 获取配置
|
||||
console.log(ENV_CONFIG.APP_TITLE)
|
||||
console.log(ENV_CONFIG.API_BASE_URL)
|
||||
|
||||
// 环境判断
|
||||
if (isDev()) {
|
||||
// 开发环境逻辑
|
||||
}
|
||||
|
||||
// 调试日志(只在DEBUG_MODE=true时输出)
|
||||
debugLog('调试信息', data)
|
||||
```
|
||||
|
||||
### 2. 运行不同环境
|
||||
|
||||
#### 开发环境
|
||||
```bash
|
||||
npm run dev # 使用 .env.development
|
||||
npm run dev:test # 使用 .env.test
|
||||
```
|
||||
|
||||
#### 构建不同环境
|
||||
```bash
|
||||
npm run build # 生产环境构建
|
||||
npm run build:test # 测试环境构建
|
||||
npm run build:dev # 开发环境构建
|
||||
```
|
||||
|
||||
#### 预览
|
||||
```bash
|
||||
npm run preview # 预览生产环境构建
|
||||
npm run preview:test # 预览测试环境构建
|
||||
```
|
||||
|
||||
## 配置文件位置
|
||||
|
||||
- 环境配置: `src/config/env.js`
|
||||
- API配置: `src/api/request.js`
|
||||
- 使用示例: `src/utils/env-example.js`
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **环境变量必须以 `VITE_` 开头**才能在客户端代码中访问
|
||||
2. **不要在环境变量中存储敏感信息**(如密钥、密码等)
|
||||
3. **修改环境变量后需要重启开发服务器**
|
||||
4. **生产环境的API地址需要根据实际部署情况修改**
|
||||
|
||||
## 自定义环境变量
|
||||
|
||||
如需添加新的环境变量:
|
||||
|
||||
1. 在相应的 `.env.*` 文件中添加变量(以 `VITE_` 开头)
|
||||
2. 在 `src/config/env.js` 中添加对应的配置项
|
||||
3. 在代码中通过 `ENV_CONFIG` 对象访问
|
||||
|
||||
示例:
|
||||
```bash
|
||||
# .env.development
|
||||
VITE_FEATURE_NEW_UI=true
|
||||
```
|
||||
|
||||
```javascript
|
||||
// src/config/env.js
|
||||
const getEnvConfig = () => {
|
||||
return {
|
||||
// ... 其他配置
|
||||
FEATURE_NEW_UI: import.meta.env.VITE_FEATURE_NEW_UI === 'true'
|
||||
}
|
||||
}
|
||||
```
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
# 情绪博物馆 Web 前端
|
||||
|
||||
一个基于 Vue 3 + Ant Design Vue 的现代化情绪分析和AI对话前端应用。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 🎨 **现代化UI设计** - 采用渐变色彩和玻璃态效果,提供优雅的视觉体验
|
||||
- 🤖 **AI智能对话** - 与AI助手进行自然流畅的对话交流
|
||||
- 📊 **情绪分析** - 实时分析用户情绪状态,提供专业的心理健康评估
|
||||
- 📱 **响应式设计** - 完美适配桌面端和移动端设备
|
||||
- 🔄 **实时交互** - 支持实时消息推送和状态更新
|
||||
- 📈 **数据可视化** - 情绪趋势图表和统计分析
|
||||
- 🎯 **用户体验** - 流畅的动画效果和交互反馈
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **框架**: Vue 3 (Composition API)
|
||||
- **UI库**: Ant Design Vue 4.x
|
||||
- **路由**: Vue Router 4
|
||||
- **状态管理**: Pinia
|
||||
- **构建工具**: Vite
|
||||
- **样式**: SCSS
|
||||
- **HTTP客户端**: Axios
|
||||
- **时间处理**: Day.js
|
||||
- **图标**: Ant Design Icons
|
||||
|
||||
## 📦 项目结构
|
||||
|
||||
```
|
||||
web/
|
||||
├── public/ # 静态资源
|
||||
├── src/
|
||||
│ ├── api/ # API接口
|
||||
│ │ ├── request.js # 请求封装
|
||||
│ │ └── chat.js # 聊天相关API
|
||||
│ ├── components/ # 公共组件
|
||||
│ │ ├── EmotionAnalysis.vue # 情绪分析组件
|
||||
│ │ ├── HistoryPanel.vue # 历史记录面板
|
||||
│ │ ├── ConversationDetail.vue # 对话详情
|
||||
│ │ └── EmotionTrends.vue # 情绪趋势图表
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── stores/ # 状态管理
|
||||
│ │ ├── user.js # 用户状态
|
||||
│ │ └── chat.js # 聊天状态
|
||||
│ ├── styles/ # 全局样式
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── views/ # 页面组件
|
||||
│ │ ├── Home.vue # 首页
|
||||
│ │ ├── Chat.vue # 聊天页面
|
||||
│ │ ├── History.vue # 历史记录页面
|
||||
│ │ └── Analysis.vue # 情绪分析页面
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.js # 入口文件
|
||||
├── index.html # HTML模板
|
||||
├── package.json # 依赖配置
|
||||
├── vite.config.js # Vite配置
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 16.0.0
|
||||
- npm >= 8.0.0 或 yarn >= 1.22.0
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm install
|
||||
|
||||
# 或使用 yarn
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
# 或
|
||||
yarn dev
|
||||
```
|
||||
|
||||
访问 http://localhost:3000 查看应用
|
||||
|
||||
### 生产构建
|
||||
|
||||
```bash
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 或
|
||||
yarn build
|
||||
```
|
||||
|
||||
### 预览生产版本
|
||||
|
||||
```bash
|
||||
# 预览构建结果
|
||||
npm run preview
|
||||
|
||||
# 或
|
||||
yarn preview
|
||||
```
|
||||
|
||||
## 🎨 设计系统
|
||||
|
||||
### 色彩方案
|
||||
|
||||
- **主色调**: 渐变紫蓝色 (#667eea → #764ba2)
|
||||
- **辅助色**: 渐变粉红色 (#f093fb → #f5576c)
|
||||
- **成功色**: 渐变蓝绿色 (#4facfe → #00f2fe)
|
||||
- **文字色**: 深灰色系 (#2c3e50, #7f8c8d, #bdc3c7)
|
||||
|
||||
### 组件特性
|
||||
|
||||
- **玻璃态效果**: 半透明背景 + 模糊滤镜
|
||||
- **渐变按钮**: 主题色渐变 + 悬停效果
|
||||
- **消息气泡**: 用户/AI区分设计
|
||||
- **动画效果**: 淡入淡出、滑动、弹跳等
|
||||
|
||||
## 📱 页面功能
|
||||
|
||||
### 首页 (Home)
|
||||
- 产品介绍和特性展示
|
||||
- 快速开始对话入口
|
||||
- 统计数据展示
|
||||
- 响应式导航菜单
|
||||
|
||||
### 聊天页面 (Chat)
|
||||
- 侧边栏会话列表
|
||||
- 实时消息交互
|
||||
- 情绪分析集成
|
||||
- 消息历史记录
|
||||
- 打字状态指示
|
||||
|
||||
### 历史记录 (History)
|
||||
- 会话列表管理
|
||||
- 搜索和筛选功能
|
||||
- 对话详情查看
|
||||
- 导出和分享功能
|
||||
- 统计数据概览
|
||||
|
||||
### 情绪分析 (Analysis)
|
||||
- 快速文本分析
|
||||
- 历史分析记录
|
||||
- 情绪趋势图表
|
||||
- 数据洞察建议
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
创建 `.env.local` 文件配置环境变量:
|
||||
|
||||
```env
|
||||
# API基础URL
|
||||
VITE_API_BASE_URL=http://localhost:9001
|
||||
|
||||
# 应用标题
|
||||
VITE_APP_TITLE=情绪博物馆
|
||||
|
||||
# 是否启用调试模式
|
||||
VITE_DEBUG=true
|
||||
```
|
||||
|
||||
### 代理配置
|
||||
|
||||
开发环境下,Vite会自动代理 `/api` 请求到后端服务:
|
||||
|
||||
```javascript
|
||||
// vite.config.js
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:9001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 🎯 核心功能
|
||||
|
||||
### 状态管理
|
||||
|
||||
使用 Pinia 管理应用状态:
|
||||
|
||||
- **用户状态**: 用户信息、登录状态
|
||||
- **聊天状态**: 会话列表、当前对话、消息记录
|
||||
|
||||
### API集成
|
||||
|
||||
- 统一的请求封装和错误处理
|
||||
- 自动重试和超时控制
|
||||
- 请求/响应拦截器
|
||||
- 加载状态管理
|
||||
|
||||
### 响应式设计
|
||||
|
||||
- 移动端优先设计
|
||||
- 断点适配 (768px, 1024px, 1200px)
|
||||
- 触摸友好的交互
|
||||
- 自适应布局
|
||||
|
||||
## 🔍 开发指南
|
||||
|
||||
### 代码规范
|
||||
|
||||
- 使用 ESLint + Prettier 进行代码格式化
|
||||
- 组件命名采用 PascalCase
|
||||
- 文件命名采用 kebab-case
|
||||
- 样式使用 SCSS 预处理器
|
||||
|
||||
### 组件开发
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="component-name">
|
||||
<!-- 模板内容 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// 组件逻辑
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.component-name {
|
||||
// 样式定义
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### API调用
|
||||
|
||||
```javascript
|
||||
import { chatApi } from '@/api/chat'
|
||||
|
||||
// 发送消息
|
||||
const response = await chatApi.sendMessage({
|
||||
userId: 'user123',
|
||||
message: 'Hello',
|
||||
conversationId: 'conv456'
|
||||
})
|
||||
```
|
||||
|
||||
## 🚀 部署
|
||||
|
||||
### 静态部署
|
||||
|
||||
构建后的 `dist` 目录可以部署到任何静态文件服务器:
|
||||
|
||||
- Nginx
|
||||
- Apache
|
||||
- Vercel
|
||||
- Netlify
|
||||
- GitHub Pages
|
||||
|
||||
### Docker部署
|
||||
|
||||
```dockerfile
|
||||
FROM nginx:alpine
|
||||
COPY dist/ /usr/share/nginx/html/
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- [Vue.js](https://vuejs.org/) - 渐进式JavaScript框架
|
||||
- [Ant Design Vue](https://antdv.com/) - 企业级UI设计语言
|
||||
- [Vite](https://vitejs.dev/) - 下一代前端构建工具
|
||||
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>情绪博物馆 - AI心理健康助手</title>
|
||||
<meta name="description" content="情绪博物馆 - 您的专属AI心理健康助手,提供情绪分析、心理支持和个性化建议" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="loading">加载中...</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,633 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>情绪博物馆 - 移动版</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: white;
|
||||
overflow-x: hidden;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mobile-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.new-chat-btn:active {
|
||||
background: rgba(255,255,255,0.3);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: rgba(0,0,0,0.1);
|
||||
padding: 8px 15px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #4CAF50;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 12px 0;
|
||||
padding: 12px 16px;
|
||||
border-radius: 18px;
|
||||
max-width: 85%;
|
||||
word-wrap: break-word;
|
||||
line-height: 1.4;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
|
||||
margin-left: auto;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
|
||||
.ai-message {
|
||||
background: rgba(255,255,255,0.95);
|
||||
color: #333;
|
||||
margin-right: auto;
|
||||
border-bottom-left-radius: 6px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(244, 67, 54, 0.2) !important;
|
||||
border-left: 4px solid #f44336;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 15px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-top: 1px solid rgba(255,255,255,0.2);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
background: rgba(255,255,255,0.9);
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.message-input:focus {
|
||||
background: rgba(255,255,255,1);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
min-width: 60px;
|
||||
min-height: 44px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.send-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.typing-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #999;
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-dot:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-dot:nth-child(2) { animation-delay: -0.16s; }
|
||||
.typing-dot:nth-child(3) { animation-delay: 0s; }
|
||||
|
||||
.emotion-tag {
|
||||
display: inline-block;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #667eea;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.welcome-message {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.welcome-message h2 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.welcome-message p {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 600px) {
|
||||
.chat-area {
|
||||
padding: 10px;
|
||||
}
|
||||
.message {
|
||||
margin: 8px 0;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (orientation: landscape) and (max-height: 500px) {
|
||||
.header {
|
||||
padding: 8px 15px;
|
||||
}
|
||||
.input-container {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="mobile-container">
|
||||
<div class="header">
|
||||
<h1>🏛️ 情绪博物馆</h1>
|
||||
<button class="new-chat-btn" onclick="startNewChat()">新对话</button>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="connection-status">
|
||||
<div class="status-dot" id="status-dot"></div>
|
||||
<span id="connection-text">连接中...</span>
|
||||
</div>
|
||||
<span id="message-count">0 条消息</span>
|
||||
</div>
|
||||
|
||||
<div class="chat-area" id="chat-area">
|
||||
<div class="welcome-message">
|
||||
<h2>👋 欢迎使用</h2>
|
||||
<p>我是您的AI心理健康助手,很高兴为您服务。请告诉我您今天的心情如何?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-container">
|
||||
<div class="input-wrapper">
|
||||
<textarea
|
||||
class="message-input"
|
||||
id="message-input"
|
||||
placeholder="输入您想说的话..."
|
||||
rows="1"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<button class="send-btn" onclick="sendMessage()" id="send-btn">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// 全局变量
|
||||
let conversationId = null;
|
||||
let messageCount = 0;
|
||||
let isConnected = false;
|
||||
let isSending = false;
|
||||
|
||||
// 防止iOS Safari的双击缩放
|
||||
let lastTouchEnd = 0;
|
||||
document.addEventListener('touchend', function (event) {
|
||||
const now = (new Date()).getTime();
|
||||
if (now - lastTouchEnd <= 300) {
|
||||
event.preventDefault();
|
||||
}
|
||||
lastTouchEnd = now;
|
||||
}, false);
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('页面加载完成');
|
||||
initializeApp();
|
||||
});
|
||||
|
||||
function initializeApp() {
|
||||
try {
|
||||
checkConnection();
|
||||
setupInputHandlers();
|
||||
adjustViewport();
|
||||
console.log('应用初始化完成');
|
||||
} catch (error) {
|
||||
console.error('初始化失败:', error);
|
||||
showError('应用初始化失败,请刷新页面重试');
|
||||
}
|
||||
}
|
||||
|
||||
function adjustViewport() {
|
||||
function setViewportHeight() {
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty('--vh', vh + 'px');
|
||||
}
|
||||
|
||||
setViewportHeight();
|
||||
window.addEventListener('resize', setViewportHeight);
|
||||
window.addEventListener('orientationchange', function() {
|
||||
setTimeout(setViewportHeight, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function setupInputHandlers() {
|
||||
const input = document.getElementById('message-input');
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
if (/iPhone|iPad|iPod/.test(navigator.userAgent)) {
|
||||
input.addEventListener('focus', function() {
|
||||
setTimeout(function() {
|
||||
window.scrollTo(0, 0);
|
||||
document.body.scrollTop = 0;
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function checkConnection() {
|
||||
const statusDot = document.getElementById('status-dot');
|
||||
const connectionText = document.getElementById('connection-text');
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch('/api/ai/guest/health', {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.code === 200 && data.success) {
|
||||
isConnected = true;
|
||||
statusDot.className = 'status-dot';
|
||||
connectionText.textContent = '已连接';
|
||||
} else {
|
||||
throw new Error('服务不可用');
|
||||
}
|
||||
} else {
|
||||
throw new Error('HTTP ' + response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
isConnected = false;
|
||||
statusDot.className = 'status-dot error';
|
||||
connectionText.textContent = '连接失败';
|
||||
console.error('连接检查失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMessageCount() {
|
||||
document.getElementById('message-count').textContent = messageCount + ' 条消息';
|
||||
}
|
||||
|
||||
function startNewChat() {
|
||||
try {
|
||||
conversationId = null;
|
||||
messageCount = 0;
|
||||
const chatArea = document.getElementById('chat-area');
|
||||
chatArea.innerHTML =
|
||||
'<div class="welcome-message">' +
|
||||
'<h2>🆕 新对话开始</h2>' +
|
||||
'<p>我是您的AI心理健康助手,很高兴为您服务。请告诉我您今天的心情如何?</p>' +
|
||||
'</div>';
|
||||
updateMessageCount();
|
||||
clearInputAndFocus();
|
||||
} catch (error) {
|
||||
console.error('创建新对话失败:', error);
|
||||
showError('创建新对话失败');
|
||||
}
|
||||
}
|
||||
|
||||
function clearInputAndFocus() {
|
||||
const input = document.getElementById('message-input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
if (!isMobile()) {
|
||||
setTimeout(() => input.focus(), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isMobile() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const chatArea = document.getElementById('chat-area');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'message ai-message error-message';
|
||||
errorDiv.innerHTML = '<strong>⚠️ 错误:</strong> ' + escapeHtml(message);
|
||||
chatArea.appendChild(errorDiv);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const chatArea = document.getElementById('chat-area');
|
||||
setTimeout(function() {
|
||||
chatArea.scrollTop = chatArea.scrollHeight;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (isSending) return;
|
||||
|
||||
const input = document.getElementById('message-input');
|
||||
const chatArea = document.getElementById('chat-area');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
|
||||
const message = input.value.trim();
|
||||
if (!message) {
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
isSending = true;
|
||||
|
||||
try {
|
||||
// 立即清空输入框
|
||||
clearInputAndFocus();
|
||||
|
||||
// 添加用户消息
|
||||
const userMessageDiv = document.createElement('div');
|
||||
userMessageDiv.className = 'message user-message';
|
||||
userMessageDiv.innerHTML = '<strong>👤 您:</strong> ' + escapeHtml(message);
|
||||
chatArea.appendChild(userMessageDiv);
|
||||
|
||||
// 显示AI正在输入
|
||||
const loadingDiv = document.createElement('div');
|
||||
loadingDiv.className = 'message ai-message';
|
||||
loadingDiv.innerHTML =
|
||||
'<strong>🤖 AI助手:</strong> 正在思考中' +
|
||||
'<div class="typing-indicator">' +
|
||||
'<div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div>' +
|
||||
'</div>';
|
||||
chatArea.appendChild(loadingDiv);
|
||||
|
||||
// 禁用发送按钮
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = '发送中...';
|
||||
|
||||
scrollToBottom();
|
||||
|
||||
// 发送请求
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
const response = await fetch('/api/ai/guest/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
title: conversationId ? null : '移动端对话 ' + new Date().toLocaleString()
|
||||
}),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// 移除加载状态
|
||||
if (chatArea.contains(loadingDiv)) {
|
||||
chatArea.removeChild(loadingDiv);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP ' + response.status);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code === 200 && data.success) {
|
||||
// 更新会话ID
|
||||
if (data.data.conversationId) {
|
||||
conversationId = data.data.conversationId;
|
||||
}
|
||||
|
||||
// 添加AI回复
|
||||
const aiMessageDiv = document.createElement('div');
|
||||
aiMessageDiv.className = 'message ai-message';
|
||||
let aiContent = '<strong>🤖 AI助手:</strong> ' + escapeHtml(data.data.aiReply);
|
||||
|
||||
// 如果有情绪分析,显示
|
||||
if (data.data.emotionAnalysis) {
|
||||
const emotion = data.data.emotionAnalysis;
|
||||
aiContent += '<div class="emotion-tag">💭 ' +
|
||||
escapeHtml(emotion.primaryEmotion) + ' (强度: ' + escapeHtml(emotion.intensity) + ')</div>';
|
||||
}
|
||||
|
||||
aiMessageDiv.innerHTML = aiContent;
|
||||
chatArea.appendChild(aiMessageDiv);
|
||||
messageCount += 2;
|
||||
updateMessageCount();
|
||||
|
||||
// 更新连接状态
|
||||
if (!isConnected) {
|
||||
checkConnection();
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.message || '未知错误');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
|
||||
// 移除加载状态
|
||||
const loadingDiv = chatArea.querySelector('.message.ai-message:last-child');
|
||||
if (loadingDiv && loadingDiv.innerHTML.includes('正在思考中')) {
|
||||
chatArea.removeChild(loadingDiv);
|
||||
}
|
||||
|
||||
// 显示错误消息
|
||||
let errorMessage = '发送失败,请重试';
|
||||
if (error.name === 'AbortError') {
|
||||
errorMessage = '请求超时,请检查网络连接';
|
||||
} else if (error.message.includes('HTTP')) {
|
||||
errorMessage = '服务器连接失败: ' + error.message;
|
||||
}
|
||||
|
||||
showError(errorMessage);
|
||||
checkConnection();
|
||||
} finally {
|
||||
isSending = false;
|
||||
|
||||
// 恢复发送按钮
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = '发送';
|
||||
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
// 定期检查连接状态
|
||||
setInterval(function() {
|
||||
if (!isSending) {
|
||||
checkConnection();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// 页面可见性变化时重新检查连接
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (!document.hidden && !isSending) {
|
||||
setTimeout(checkConnection, 1000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
# Gzip压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Vary "Accept-Encoding";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# HTML文件不缓存
|
||||
location ~* \.(html|htm)$ {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# SPA路由支持
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# 错误页面
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
Generated
+5137
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "emotion-museum-web",
|
||||
"version": "1.0.0",
|
||||
"description": "情绪博物馆前端应用",
|
||||
"scripts": {
|
||||
"dev": "vite --mode development",
|
||||
"dev:test": "vite --mode test",
|
||||
"build": "vite build --mode production",
|
||||
"build:test": "vite build --mode test",
|
||||
"build:dev": "vite build --mode development",
|
||||
"preview": "vite preview",
|
||||
"preview:test": "vite preview --mode test",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0",
|
||||
"pinia": "^2.1.0",
|
||||
"ant-design-vue": "^4.0.0",
|
||||
"@ant-design/icons-vue": "^6.1.0",
|
||||
"axios": "^1.5.0",
|
||||
"dayjs": "^1.11.0",
|
||||
"marked": "^9.1.0",
|
||||
"highlight.js": "^11.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.4.0",
|
||||
"vite": "^4.5.0",
|
||||
"eslint": "^8.50.0",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"sass": "^1.69.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
<!-- 环境信息组件(仅在非生产环境显示) -->
|
||||
<EnvInfo />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { debugLog } from '@/config/env'
|
||||
import EnvInfo from '@/components/EnvInfo.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
// 初始化用户信息
|
||||
userStore.initUser()
|
||||
debugLog('App.vue loaded successfully, user:', userStore.userInfo)
|
||||
} catch (error) {
|
||||
console.error('App.vue 初始化失败:', error)
|
||||
// 确保有一个默认的访客用户状态
|
||||
userStore.setGuestMode()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
// 全局滚动条样式
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 验证码相关API
|
||||
*/
|
||||
export const captchaApi = {
|
||||
// 生成图形验证码
|
||||
generate(type = 'arithmetic') {
|
||||
return request.get('/captcha/generate', {
|
||||
params: { type }
|
||||
})
|
||||
},
|
||||
|
||||
// 验证图形验证码
|
||||
verify(captchaId, captcha) {
|
||||
return request.post('/captcha/verify', null, {
|
||||
params: { captchaId, captcha }
|
||||
})
|
||||
},
|
||||
|
||||
// 生成滑块验证码
|
||||
generateSlider() {
|
||||
return request.get('/captcha/slider/generate')
|
||||
},
|
||||
|
||||
// 验证滑块验证码
|
||||
verifySlider(data) {
|
||||
return request.post('/captcha/slider/verify', data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* AI聊天相关API
|
||||
* 对应后端 AiChatController (/api/ai/chat)
|
||||
*/
|
||||
export const chatApi = {
|
||||
// 创建会话
|
||||
createConversation(data) {
|
||||
return request.post('/ai/chat/conversation/create', data)
|
||||
},
|
||||
|
||||
// 发送聊天消息
|
||||
sendMessage(data) {
|
||||
return request.post('/ai/chat/send', data)
|
||||
},
|
||||
|
||||
// 流式聊天
|
||||
streamChat(data) {
|
||||
return request.post('/ai/chat/stream', data)
|
||||
},
|
||||
|
||||
// 情绪分析
|
||||
analyzeEmotion(data) {
|
||||
return request.post('/ai/chat/emotion/analyze', data)
|
||||
},
|
||||
|
||||
// 获取用户会话列表
|
||||
getConversations(userId, pageNum = 1, pageSize = 20) {
|
||||
return request.get(`/ai/chat/conversations/${userId}`, {
|
||||
params: { pageNum, pageSize }
|
||||
})
|
||||
},
|
||||
|
||||
// 获取会话详情
|
||||
getConversation(conversationId) {
|
||||
return request.get(`/ai/chat/conversation/${conversationId}`)
|
||||
},
|
||||
|
||||
// 获取会话消息列表
|
||||
getMessages(conversationId, pageNum = 1, pageSize = 50) {
|
||||
return request.get(`/ai/chat/conversation/${conversationId}/messages`, {
|
||||
params: { pageNum, pageSize }
|
||||
})
|
||||
},
|
||||
|
||||
// 结束会话
|
||||
endConversation(conversationId) {
|
||||
return request.put(`/ai/chat/conversation/${conversationId}/end`)
|
||||
},
|
||||
|
||||
// 删除会话
|
||||
deleteConversation(conversationId) {
|
||||
return request.delete(`/ai/chat/conversation/${conversationId}`)
|
||||
},
|
||||
|
||||
// 标记消息已读
|
||||
markMessageAsRead(messageId) {
|
||||
return request.put(`/ai/chat/message/${messageId}/read`)
|
||||
},
|
||||
|
||||
// 标记会话所有消息已读
|
||||
markConversationAsRead(conversationId) {
|
||||
return request.put(`/ai/chat/conversation/${conversationId}/read`)
|
||||
},
|
||||
|
||||
// 健康检查
|
||||
healthCheck() {
|
||||
return request.get('/ai/chat/health')
|
||||
},
|
||||
|
||||
// 获取AI服务信息
|
||||
getServiceInfo() {
|
||||
return request.get('/ai/chat/info')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 访客聊天相关API
|
||||
* 对应后端 GuestChatController (/api/ai/guest)
|
||||
*/
|
||||
export const guestChatApi = {
|
||||
// 访客聊天
|
||||
guestChat(data) {
|
||||
return request.post('/ai/guest/chat', data)
|
||||
},
|
||||
|
||||
// 获取访客会话列表
|
||||
getGuestConversations(pageNum = 1, pageSize = 20) {
|
||||
return request.get('/ai/guest/conversations', {
|
||||
params: { pageNum, pageSize }
|
||||
})
|
||||
},
|
||||
|
||||
// 获取访客会话消息
|
||||
getGuestConversationMessages(conversationId, pageNum = 1, pageSize = 50) {
|
||||
return request.get(`/ai/guest/conversation/${conversationId}/messages`, {
|
||||
params: { pageNum, pageSize }
|
||||
})
|
||||
},
|
||||
|
||||
// 结束访客会话
|
||||
endGuestConversation(conversationId) {
|
||||
return request.post(`/ai/guest/conversation/${conversationId}/end`)
|
||||
},
|
||||
|
||||
// 获取访客用户信息
|
||||
getGuestUserInfo() {
|
||||
return request.get('/ai/guest/user/info')
|
||||
},
|
||||
|
||||
// 访客情绪分析
|
||||
analyzeGuestEmotion(data) {
|
||||
return request.post('/ai/guest/emotion/analyze', data)
|
||||
},
|
||||
|
||||
// 访客服务健康检查
|
||||
guestHealthCheck() {
|
||||
return request.get('/ai/guest/health')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 第三方登录相关API
|
||||
*/
|
||||
export const oauthApi = {
|
||||
// 获取第三方登录授权URL
|
||||
getAuthUrl(platform) {
|
||||
return request.get(`/oauth/auth-url/${platform}`)
|
||||
},
|
||||
|
||||
// 第三方登录
|
||||
login(data) {
|
||||
return request.post('/oauth/login', data)
|
||||
},
|
||||
|
||||
// 获取第三方用户信息
|
||||
getUserInfo(platform, code, state) {
|
||||
return request.get(`/oauth/user-info/${platform}`, {
|
||||
params: { code, state }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import axios from 'axios'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ENV_CONFIG, debugLog } from '@/config/env'
|
||||
import { AuthUtils } from '@/utils/auth'
|
||||
|
||||
// 创建axios实例
|
||||
const request = axios.create({
|
||||
baseURL: ENV_CONFIG.API_BASE_URL,
|
||||
timeout: ENV_CONFIG.API_TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 打印环境信息
|
||||
if (ENV_CONFIG.DEBUG_MODE) {
|
||||
console.log('=== API配置信息 ===')
|
||||
console.log('Base URL:', ENV_CONFIG.API_BASE_URL)
|
||||
console.log('Timeout:', ENV_CONFIG.API_TIMEOUT)
|
||||
console.log('Environment:', ENV_CONFIG.APP_ENV)
|
||||
console.log('================')
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
async (config) => {
|
||||
// 自动刷新token(如果需要)
|
||||
const token = await AuthUtils.autoRefreshToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 使用环境配置的调试日志
|
||||
debugLog('发送请求:', config.method?.toUpperCase(), config.url, config.data || config.params)
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
debugLog('请求错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const { data } = response
|
||||
debugLog('收到响应:', response.config.url, data)
|
||||
|
||||
// 统一处理响应格式
|
||||
if (data.code === 200) {
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
message: data.message
|
||||
}
|
||||
} else {
|
||||
// 业务错误
|
||||
const errorMsg = data.message || '请求失败'
|
||||
message.error(errorMsg)
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: errorMsg
|
||||
}
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
debugLog('响应错误:', error)
|
||||
|
||||
let errorMsg = '网络错误'
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
errorMsg = data.message || '请求参数错误'
|
||||
break
|
||||
case 401:
|
||||
errorMsg = '未授权,请重新登录'
|
||||
// 处理登录过期
|
||||
AuthUtils.clearTokens()
|
||||
// 跳转到登录页
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
errorMsg = '拒绝访问'
|
||||
break
|
||||
case 404:
|
||||
errorMsg = '请求的资源不存在'
|
||||
break
|
||||
case 500:
|
||||
errorMsg = '服务器内部错误'
|
||||
break
|
||||
default:
|
||||
errorMsg = data.message || `请求失败 (${status})`
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMsg = '网络连接失败,请检查网络'
|
||||
} else {
|
||||
errorMsg = error.message || '请求配置错误'
|
||||
}
|
||||
|
||||
message.error(errorMsg)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: errorMsg
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,302 @@
|
||||
import { userApi } from './user'
|
||||
import { chatApi, guestChatApi } from './chat'
|
||||
import { debugLog } from '@/config/env'
|
||||
|
||||
/**
|
||||
* API测试工具
|
||||
* 用于测试前后端接口连通性
|
||||
*/
|
||||
export const apiTest = {
|
||||
// 测试用户服务
|
||||
async testUserService() {
|
||||
debugLog('开始测试用户服务...')
|
||||
|
||||
try {
|
||||
// 测试检查账号接口
|
||||
const accountResult = await userApi.checkAccount('test_user')
|
||||
debugLog('检查账号接口测试:', accountResult)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '用户服务连接正常',
|
||||
data: accountResult
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog('用户服务测试失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: '用户服务连接失败',
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 测试AI服务
|
||||
async testAiService() {
|
||||
debugLog('开始测试AI服务...')
|
||||
|
||||
try {
|
||||
// 测试健康检查接口
|
||||
const healthResult = await chatApi.healthCheck()
|
||||
debugLog('AI服务健康检查:', healthResult)
|
||||
|
||||
// 测试服务信息接口
|
||||
const infoResult = await chatApi.getServiceInfo()
|
||||
debugLog('AI服务信息:', infoResult)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'AI服务连接正常',
|
||||
data: {
|
||||
health: healthResult,
|
||||
info: infoResult
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog('AI服务测试失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'AI服务连接失败',
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 测试所有服务
|
||||
async testAllServices() {
|
||||
debugLog('开始测试所有服务...')
|
||||
|
||||
const results = {
|
||||
user: await this.testUserService(),
|
||||
ai: await this.testAiService()
|
||||
}
|
||||
|
||||
const allSuccess = Object.values(results).every(result => result.success)
|
||||
|
||||
debugLog('所有服务测试结果:', results)
|
||||
|
||||
return {
|
||||
success: allSuccess,
|
||||
message: allSuccess ? '所有服务连接正常' : '部分服务连接失败',
|
||||
results
|
||||
}
|
||||
},
|
||||
|
||||
// 测试用户注册流程
|
||||
async testUserRegister() {
|
||||
debugLog('开始测试用户注册流程...')
|
||||
|
||||
const testUser = {
|
||||
account: `test_${Date.now()}`,
|
||||
password: 'Test123456',
|
||||
email: `test_${Date.now()}@example.com`,
|
||||
phone: `138${Date.now().toString().slice(-8)}`,
|
||||
nickname: '测试用户'
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await userApi.register(testUser)
|
||||
debugLog('用户注册测试成功:', result)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '用户注册流程正常',
|
||||
data: result
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog('用户注册测试失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: '用户注册流程失败',
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 测试AI对话流程
|
||||
async testAiChat() {
|
||||
debugLog('开始测试AI对话流程...')
|
||||
|
||||
try {
|
||||
// 1. 创建会话
|
||||
const conversationData = {
|
||||
userId: 'test_user',
|
||||
title: '测试会话',
|
||||
type: 'chat'
|
||||
}
|
||||
|
||||
const createResult = await chatApi.createConversation(conversationData)
|
||||
debugLog('创建会话测试:', createResult)
|
||||
|
||||
if (!createResult.success) {
|
||||
throw new Error('创建会话失败')
|
||||
}
|
||||
|
||||
// 2. 发送消息
|
||||
const messageData = {
|
||||
userId: 'test_user',
|
||||
conversationId: createResult.data.conversationId,
|
||||
message: '你好,这是一条测试消息'
|
||||
}
|
||||
|
||||
const chatResult = await chatApi.sendMessage(messageData)
|
||||
debugLog('发送消息测试:', chatResult)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'AI对话流程正常',
|
||||
data: {
|
||||
conversation: createResult.data,
|
||||
chat: chatResult.data
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog('AI对话测试失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'AI对话流程失败',
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 测试情绪分析
|
||||
async testEmotionAnalysis() {
|
||||
debugLog('开始测试情绪分析...')
|
||||
|
||||
try {
|
||||
const analysisData = {
|
||||
userId: 'test_user',
|
||||
text: '我今天心情很好,阳光明媚,感觉充满了希望和活力。'
|
||||
}
|
||||
|
||||
const result = await chatApi.analyzeEmotion(analysisData)
|
||||
debugLog('情绪分析测试:', result)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '情绪分析功能正常',
|
||||
data: result.data
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog('情绪分析测试失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: '情绪分析功能失败',
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 测试访客聊天功能
|
||||
async testGuestChat() {
|
||||
debugLog('开始测试访客聊天功能...')
|
||||
|
||||
try {
|
||||
// 1. 获取访客用户信息
|
||||
const userInfoResult = await guestChatApi.getGuestUserInfo()
|
||||
debugLog('获取访客用户信息:', userInfoResult)
|
||||
|
||||
if (!userInfoResult.success) {
|
||||
throw new Error('获取访客用户信息失败')
|
||||
}
|
||||
|
||||
// 2. 发送访客聊天消息
|
||||
const chatData = {
|
||||
message: '你好,我是访客用户,这是一条测试消息。',
|
||||
title: '访客测试会话'
|
||||
}
|
||||
|
||||
const chatResult = await guestChatApi.guestChat(chatData)
|
||||
debugLog('访客聊天测试:', chatResult)
|
||||
|
||||
if (!chatResult.success) {
|
||||
throw new Error('访客聊天失败')
|
||||
}
|
||||
|
||||
// 3. 获取访客会话列表
|
||||
const conversationsResult = await guestChatApi.getGuestConversations()
|
||||
debugLog('访客会话列表:', conversationsResult)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '访客聊天功能正常',
|
||||
data: {
|
||||
userInfo: userInfoResult.data,
|
||||
chat: chatResult.data,
|
||||
conversations: conversationsResult.data
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog('访客聊天测试失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: '访客聊天功能失败',
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 测试访客情绪分析
|
||||
async testGuestEmotionAnalysis() {
|
||||
debugLog('开始测试访客情绪分析...')
|
||||
|
||||
try {
|
||||
const analysisData = {
|
||||
text: '我感到有些焦虑和不安,不知道该怎么办。'
|
||||
}
|
||||
|
||||
const result = await guestChatApi.analyzeGuestEmotion(analysisData)
|
||||
debugLog('访客情绪分析测试:', result)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '访客情绪分析功能正常',
|
||||
data: result.data
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog('访客情绪分析测试失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: '访客情绪分析功能失败',
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 测试访客服务健康检查
|
||||
async testGuestHealthCheck() {
|
||||
debugLog('开始测试访客服务健康检查...')
|
||||
|
||||
try {
|
||||
const result = await guestChatApi.guestHealthCheck()
|
||||
debugLog('访客服务健康检查:', result)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '访客服务健康检查正常',
|
||||
data: result.data
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog('访客服务健康检查失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: '访客服务健康检查失败',
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单个测试函数,方便在控制台调用
|
||||
export const {
|
||||
testUserService,
|
||||
testAiService,
|
||||
testAllServices,
|
||||
testUserRegister,
|
||||
testAiChat,
|
||||
testEmotionAnalysis,
|
||||
testGuestChat,
|
||||
testGuestEmotionAnalysis,
|
||||
testGuestHealthCheck
|
||||
} = apiTest
|
||||
@@ -0,0 +1,65 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 用户相关API
|
||||
* 对应后端 UserController (/user)
|
||||
*/
|
||||
export const userApi = {
|
||||
// 用户注册
|
||||
register(data) {
|
||||
return request.post('/user/register', data)
|
||||
},
|
||||
|
||||
// 用户登录
|
||||
login(data) {
|
||||
return request.post('/user/login', data)
|
||||
},
|
||||
|
||||
// 刷新Token
|
||||
refreshToken(refreshToken) {
|
||||
return request.post('/user/refresh', null, {
|
||||
params: { refreshToken }
|
||||
})
|
||||
},
|
||||
|
||||
// 获取用户信息
|
||||
getUserInfo(userId) {
|
||||
return request.get(`/user/info/${userId}`)
|
||||
},
|
||||
|
||||
// 更新用户信息
|
||||
updateUserInfo(userId, data) {
|
||||
return request.put(`/user/info/${userId}`, data)
|
||||
},
|
||||
|
||||
// 检查账号是否存在
|
||||
checkAccount(account) {
|
||||
return request.get('/user/check/account', {
|
||||
params: { account }
|
||||
})
|
||||
},
|
||||
|
||||
// 检查邮箱是否存在
|
||||
checkEmail(email) {
|
||||
return request.get('/user/check/email', {
|
||||
params: { email }
|
||||
})
|
||||
},
|
||||
|
||||
// 检查手机号是否存在
|
||||
checkPhone(phone) {
|
||||
return request.get('/user/check/phone', {
|
||||
params: { phone }
|
||||
})
|
||||
},
|
||||
|
||||
// 更新最后活跃时间
|
||||
updateLastActiveTime(userId) {
|
||||
return request.post(`/user/active/${userId}`)
|
||||
},
|
||||
|
||||
// 用户登出
|
||||
logout(userId) {
|
||||
return request.post(`/user/logout/${userId}`)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 环境配置管理
|
||||
*/
|
||||
|
||||
// 获取环境变量
|
||||
const getEnvConfig = () => {
|
||||
return {
|
||||
// 应用基础信息
|
||||
APP_TITLE: import.meta.env.VITE_APP_TITLE || '情绪博物馆',
|
||||
APP_VERSION: import.meta.env.VITE_APP_VERSION || '1.0.0',
|
||||
APP_ENV: import.meta.env.VITE_APP_ENV || 'development',
|
||||
|
||||
// API配置
|
||||
API_BASE_URL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
API_TARGET: import.meta.env.VITE_API_TARGET || 'http://localhost:9000',
|
||||
API_TIMEOUT: parseInt(import.meta.env.VITE_API_TIMEOUT) || 30000,
|
||||
|
||||
// 功能开关
|
||||
DEBUG_MODE: import.meta.env.VITE_DEBUG_MODE === 'true',
|
||||
MOCK_DATA: import.meta.env.VITE_MOCK_DATA === 'true',
|
||||
|
||||
// 环境判断
|
||||
isDevelopment: import.meta.env.MODE === 'development',
|
||||
isTest: import.meta.env.MODE === 'test',
|
||||
isProduction: import.meta.env.MODE === 'production'
|
||||
}
|
||||
}
|
||||
|
||||
// 导出配置
|
||||
export const ENV_CONFIG = getEnvConfig()
|
||||
|
||||
// 环境检查函数
|
||||
export const isDev = () => ENV_CONFIG.isDevelopment
|
||||
export const isTest = () => ENV_CONFIG.isTest
|
||||
export const isProd = () => ENV_CONFIG.isProduction
|
||||
|
||||
// 调试日志函数
|
||||
export const debugLog = (...args) => {
|
||||
if (ENV_CONFIG.DEBUG_MODE) {
|
||||
console.log('[DEBUG]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取完整的API URL
|
||||
export const getApiUrl = (path = '') => {
|
||||
// 所有环境都使用相对路径,通过nginx代理
|
||||
return `${ENV_CONFIG.API_BASE_URL}${path}`
|
||||
}
|
||||
|
||||
// 打印环境信息
|
||||
export const printEnvInfo = () => {
|
||||
console.log('=== 环境配置信息 ===')
|
||||
console.log('应用标题:', ENV_CONFIG.APP_TITLE)
|
||||
console.log('应用版本:', ENV_CONFIG.APP_VERSION)
|
||||
console.log('运行环境:', ENV_CONFIG.APP_ENV)
|
||||
console.log('API地址:', ENV_CONFIG.API_BASE_URL)
|
||||
console.log('调试模式:', ENV_CONFIG.DEBUG_MODE)
|
||||
console.log('==================')
|
||||
}
|
||||
|
||||
export default ENV_CONFIG
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
|
||||
// Ant Design Vue
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
|
||||
// 全局样式
|
||||
import './styles/global.scss'
|
||||
|
||||
// 环境配置
|
||||
import { ENV_CONFIG, printEnvInfo, debugLog } from '@/config/env'
|
||||
|
||||
// 用户store
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// 认证工具
|
||||
import { setupTokenRefreshTimer } from '@/utils/auth'
|
||||
|
||||
debugLog('main.js loading...')
|
||||
|
||||
// 打印环境信息
|
||||
if (ENV_CONFIG.DEBUG_MODE) {
|
||||
printEnvInfo()
|
||||
}
|
||||
|
||||
// 创建应用实例
|
||||
const app = createApp(App)
|
||||
|
||||
debugLog('App created')
|
||||
|
||||
// 使用插件
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
|
||||
debugLog('Plugins loaded')
|
||||
|
||||
// 初始化用户store
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
userStore.initUser()
|
||||
debugLog('User store initialized successfully')
|
||||
} catch (error) {
|
||||
console.error('User store initialization failed:', error)
|
||||
debugLog('User store initialization failed, using guest mode')
|
||||
}
|
||||
|
||||
// 设置应用标题
|
||||
document.title = ENV_CONFIG.APP_TITLE
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app')
|
||||
|
||||
debugLog('App mounted')
|
||||
|
||||
// 启动token自动刷新定时器
|
||||
setupTokenRefreshTimer()
|
||||
debugLog('Token refresh timer started')
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/Home.vue'),
|
||||
meta: {
|
||||
title: '情绪博物馆 - 首页'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/test',
|
||||
name: 'Test',
|
||||
component: () => import('@/views/HomeTest.vue'),
|
||||
meta: {
|
||||
title: '情绪博物馆 - 测试页面'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
name: 'Chat',
|
||||
component: () => import('@/views/ChatComplete.vue'),
|
||||
meta: {
|
||||
title: 'AI对话 - 情绪博物馆'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/history',
|
||||
name: 'History',
|
||||
component: () => import('@/views/HistorySimple.vue'),
|
||||
meta: {
|
||||
title: '对话历史 - 情绪博物馆'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/analysis',
|
||||
name: 'Analysis',
|
||||
component: () => import('@/views/AnalysisSimple.vue'),
|
||||
meta: {
|
||||
title: '情绪分析 - 情绪博物馆'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: {
|
||||
title: '登录注册 - 情绪博物馆'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title
|
||||
}
|
||||
|
||||
// 检查是否需要认证
|
||||
const requiresAuth = to.meta.requiresAuth
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (requiresAuth && !token) {
|
||||
// 需要认证但没有token,跳转到登录页
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
} else if (to.path === '/login' && token) {
|
||||
// 已登录用户访问登录页,跳转到首页
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,197 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { chatApi } from '@/api/chat'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const conversations = ref([])
|
||||
const currentConversation = ref(null)
|
||||
const messages = ref([])
|
||||
const loading = ref(false)
|
||||
const typing = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const hasConversations = computed(() => conversations.value.length > 0)
|
||||
const currentConversationId = computed(() => currentConversation.value?.conversationId)
|
||||
|
||||
// 获取会话列表
|
||||
const fetchConversations = async (userId) => {
|
||||
try {
|
||||
const response = await chatApi.getConversations(userId)
|
||||
if (response.success) {
|
||||
conversations.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取会话列表失败:', error)
|
||||
message.error('获取会话列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新会话
|
||||
const createConversation = async (params) => {
|
||||
try {
|
||||
loading.value = true
|
||||
console.log('创建会话请求参数:', params)
|
||||
|
||||
const response = await chatApi.createConversation(params)
|
||||
console.log('创建会话响应:', response)
|
||||
|
||||
if (response.success) {
|
||||
const newConversation = response.data
|
||||
// 确保会话对象有必要的属性
|
||||
const conversation = {
|
||||
conversationId: newConversation.conversationId,
|
||||
userId: newConversation.userId,
|
||||
title: newConversation.title || '新对话',
|
||||
type: newConversation.type || 'emotion_chat',
|
||||
status: newConversation.status || 'active',
|
||||
createTime: newConversation.createTime || new Date().toISOString(),
|
||||
updateTime: newConversation.updateTime || new Date().toISOString(),
|
||||
messageCount: 0
|
||||
}
|
||||
|
||||
conversations.value.unshift(conversation)
|
||||
currentConversation.value = conversation
|
||||
messages.value = []
|
||||
return conversation
|
||||
}
|
||||
throw new Error(response.message || '创建会话失败')
|
||||
} catch (error) {
|
||||
console.error('创建会话失败:', error)
|
||||
message.error(error.message || '创建会话失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = async (content, needEmotionAnalysis = true) => {
|
||||
if (!currentConversation.value) {
|
||||
message.error('请先创建会话')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
typing.value = true
|
||||
|
||||
// 添加用户消息到界面
|
||||
const userMessage = {
|
||||
id: `user_${Date.now()}`,
|
||||
content,
|
||||
sender: 'user',
|
||||
timestamp: new Date(),
|
||||
type: 'text'
|
||||
}
|
||||
messages.value.push(userMessage)
|
||||
console.log('添加用户消息:', userMessage)
|
||||
|
||||
// 发送到后端
|
||||
const requestData = {
|
||||
userId: currentConversation.value.userId,
|
||||
conversationId: currentConversation.value.conversationId,
|
||||
message: content,
|
||||
needEmotionAnalysis,
|
||||
type: 'text'
|
||||
}
|
||||
console.log('发送消息请求:', requestData)
|
||||
|
||||
const response = await chatApi.sendMessage(requestData)
|
||||
console.log('发送消息响应:', response)
|
||||
|
||||
if (response.success) {
|
||||
// 添加AI回复到界面
|
||||
const aiMessage = {
|
||||
id: response.data.messageId || `ai_${Date.now()}`,
|
||||
content: response.data.content,
|
||||
sender: 'assistant',
|
||||
timestamp: response.data.timestamp ? new Date(response.data.timestamp) : new Date(),
|
||||
type: response.data.type || 'text',
|
||||
emotionAnalysis: response.data.emotionAnalysis
|
||||
}
|
||||
messages.value.push(aiMessage)
|
||||
console.log('添加AI消息:', aiMessage)
|
||||
|
||||
// 更新会话的最后更新时间和消息数量
|
||||
if (currentConversation.value) {
|
||||
currentConversation.value.updateTime = new Date().toISOString()
|
||||
currentConversation.value.messageCount = (currentConversation.value.messageCount || 0) + 2
|
||||
}
|
||||
|
||||
return aiMessage
|
||||
}
|
||||
throw new Error(response.message || '发送消息失败')
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
message.error(error.message || '发送消息失败')
|
||||
|
||||
// 移除失败的用户消息
|
||||
messages.value = messages.value.filter(msg => msg.id !== `user_${Date.now()}`)
|
||||
throw error
|
||||
} finally {
|
||||
typing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取会话消息
|
||||
const fetchMessages = async (conversationId) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await chatApi.getMessages(conversationId)
|
||||
if (response.success) {
|
||||
messages.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取消息失败:', error)
|
||||
message.error('获取消息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换会话
|
||||
const switchConversation = async (conversation) => {
|
||||
currentConversation.value = conversation
|
||||
await fetchMessages(conversation.conversationId)
|
||||
}
|
||||
|
||||
// 清空当前会话
|
||||
const clearCurrentConversation = () => {
|
||||
currentConversation.value = null
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
// 删除会话
|
||||
const deleteConversation = async (conversationId) => {
|
||||
try {
|
||||
await chatApi.deleteConversation(conversationId)
|
||||
conversations.value = conversations.value.filter(c => c.conversationId !== conversationId)
|
||||
|
||||
if (currentConversation.value?.conversationId === conversationId) {
|
||||
clearCurrentConversation()
|
||||
}
|
||||
|
||||
message.success('删除成功')
|
||||
} catch (error) {
|
||||
console.error('删除会话失败:', error)
|
||||
message.error('删除会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
currentConversation,
|
||||
messages,
|
||||
loading,
|
||||
typing,
|
||||
hasConversations,
|
||||
currentConversationId,
|
||||
fetchConversations,
|
||||
createConversation,
|
||||
sendMessage,
|
||||
fetchMessages,
|
||||
switchConversation,
|
||||
clearCurrentConversation,
|
||||
deleteConversation
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,270 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { guestChatApi } from '@/api/chat'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
export const useGuestChatStore = defineStore('guestChat', () => {
|
||||
const conversations = ref([])
|
||||
const currentConversation = ref(null)
|
||||
const messages = ref([])
|
||||
const loading = ref(false)
|
||||
const typing = ref(false)
|
||||
const guestUserInfo = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const hasConversations = computed(() => conversations.value.length > 0)
|
||||
const currentConversationId = computed(() => currentConversation.value?.conversationId)
|
||||
|
||||
// 获取或创建访客用户信息
|
||||
const getOrCreateGuestUser = async () => {
|
||||
try {
|
||||
const response = await guestChatApi.getGuestUserInfo()
|
||||
if (response.code === 200) {
|
||||
guestUserInfo.value = response.data
|
||||
return response.data
|
||||
}
|
||||
throw new Error(response.message || '获取访客用户信息失败')
|
||||
} catch (error) {
|
||||
console.error('获取访客用户信息失败:', error)
|
||||
// 不显示错误消息,因为这是自动调用的
|
||||
// message.error('获取访客用户信息失败')
|
||||
|
||||
// 创建一个默认的访客用户信息
|
||||
const defaultGuestUser = {
|
||||
id: `guest_${Date.now()}`,
|
||||
name: '访客用户',
|
||||
isGuest: true,
|
||||
createTime: new Date().toISOString()
|
||||
}
|
||||
guestUserInfo.value = defaultGuestUser
|
||||
return defaultGuestUser
|
||||
}
|
||||
}
|
||||
|
||||
// 获取访客会话列表
|
||||
const fetchConversations = async () => {
|
||||
try {
|
||||
const response = await guestChatApi.getGuestConversations()
|
||||
if (response.code === 200) {
|
||||
conversations.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取会话列表失败:', error)
|
||||
// 不显示错误消息,因为这是自动调用的
|
||||
// message.error('获取会话列表失败')
|
||||
conversations.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 发送访客聊天消息
|
||||
const sendMessage = async (content, title = null) => {
|
||||
try {
|
||||
typing.value = true
|
||||
|
||||
// 添加用户消息到界面
|
||||
const userMessage = {
|
||||
id: `user_${Date.now()}`,
|
||||
content,
|
||||
sender: 'user',
|
||||
timestamp: new Date(),
|
||||
type: 'text'
|
||||
}
|
||||
messages.value.push(userMessage)
|
||||
console.log('添加用户消息:', userMessage)
|
||||
|
||||
// 发送到后端
|
||||
const requestData = {
|
||||
message: content,
|
||||
title: title || (currentConversation.value ? null : `对话 ${new Date().toLocaleString()}`),
|
||||
messageType: 'text'
|
||||
}
|
||||
console.log('发送访客聊天请求:', requestData)
|
||||
|
||||
const response = await guestChatApi.guestChat(requestData)
|
||||
console.log('访客聊天响应:', response)
|
||||
|
||||
if (response.code === 200) {
|
||||
const data = response.data
|
||||
|
||||
// 如果是新会话,更新当前会话信息
|
||||
if (data.isNewConversation || !currentConversation.value) {
|
||||
const newConversation = {
|
||||
conversationId: data.conversationId,
|
||||
userId: data.guestUserId,
|
||||
title: data.conversationTitle || title || `对话 ${new Date().toLocaleString()}`,
|
||||
type: 'guest_chat',
|
||||
status: data.conversationStatus || 'active',
|
||||
createTime: data.timestamp || new Date().toISOString(),
|
||||
updateTime: data.timestamp || new Date().toISOString(),
|
||||
messageCount: 2
|
||||
}
|
||||
|
||||
currentConversation.value = newConversation
|
||||
|
||||
// 添加到会话列表(如果不存在)
|
||||
const existingIndex = conversations.value.findIndex(c => c.conversationId === newConversation.conversationId)
|
||||
if (existingIndex === -1) {
|
||||
conversations.value.unshift(newConversation)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户消息ID
|
||||
if (data.userMessageId) {
|
||||
userMessage.id = data.userMessageId
|
||||
}
|
||||
|
||||
// 处理AI回复 - 支持多条消息
|
||||
if (data.multipleMessages && data.messageCount > 1) {
|
||||
// 多条消息的情况 - 从数据库获取最新消息
|
||||
console.log('检测到多条消息,从数据库获取最新消息')
|
||||
if (currentConversation.value) {
|
||||
await fetchMessages(currentConversation.value.conversationId)
|
||||
}
|
||||
} else {
|
||||
// 单条消息的情况 - 直接添加到界面
|
||||
const aiMessage = {
|
||||
id: data.aiMessageId || `ai_${Date.now()}`,
|
||||
content: data.aiReply,
|
||||
sender: 'assistant',
|
||||
timestamp: data.timestamp ? new Date(data.timestamp) : new Date(),
|
||||
type: 'text',
|
||||
emotionAnalysis: data.emotionAnalysis
|
||||
}
|
||||
messages.value.push(aiMessage)
|
||||
console.log('添加AI消息:', aiMessage)
|
||||
}
|
||||
|
||||
// 更新会话的最后更新时间和消息数量
|
||||
if (currentConversation.value) {
|
||||
currentConversation.value.updateTime = new Date().toISOString()
|
||||
currentConversation.value.messageCount = (currentConversation.value.messageCount || 0) + 2
|
||||
}
|
||||
|
||||
return aiMessage
|
||||
}
|
||||
throw new Error(response.message || '发送消息失败')
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
message.error(error.message || '发送消息失败')
|
||||
|
||||
// 移除失败的用户消息
|
||||
messages.value = messages.value.filter(msg => msg.id !== `user_${Date.now()}`)
|
||||
throw error
|
||||
} finally {
|
||||
typing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取会话消息
|
||||
const fetchMessages = async (conversationId) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await guestChatApi.getGuestConversationMessages(conversationId)
|
||||
if (response.code === 200) {
|
||||
const rawMessages = response.data || []
|
||||
// 转换消息格式以适配前端显示
|
||||
messages.value = rawMessages.map(msg => ({
|
||||
id: msg.messageId || msg.id,
|
||||
content: msg.content,
|
||||
sender: msg.sender === 'user' ? 'user' : 'assistant',
|
||||
timestamp: new Date(msg.timestamp),
|
||||
type: msg.type || 'text',
|
||||
emotionAnalysis: msg.emotionAnalysis ? {
|
||||
emotionType: msg.emotionType,
|
||||
emotionScore: msg.emotionScore,
|
||||
emotionConfidence: msg.emotionConfidence
|
||||
} : null
|
||||
}))
|
||||
console.log('获取到消息:', messages.value.length, '条')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取消息失败:', error)
|
||||
message.error('获取消息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换会话
|
||||
const switchConversation = async (conversation) => {
|
||||
currentConversation.value = conversation
|
||||
await fetchMessages(conversation.conversationId)
|
||||
}
|
||||
|
||||
// 清空当前会话
|
||||
const clearCurrentConversation = () => {
|
||||
currentConversation.value = null
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
// 结束会话
|
||||
const endConversation = async (conversationId) => {
|
||||
try {
|
||||
await guestChatApi.endGuestConversation(conversationId)
|
||||
|
||||
// 更新会话状态
|
||||
const conversation = conversations.value.find(c => c.conversationId === conversationId)
|
||||
if (conversation) {
|
||||
conversation.status = 'ended'
|
||||
}
|
||||
|
||||
if (currentConversation.value?.conversationId === conversationId) {
|
||||
clearCurrentConversation()
|
||||
}
|
||||
|
||||
message.success('会话已结束')
|
||||
} catch (error) {
|
||||
console.error('结束会话失败:', error)
|
||||
message.error('结束会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新会话(访客模式下通过发送第一条消息自动创建)
|
||||
const createNewConversation = () => {
|
||||
clearCurrentConversation()
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// 初始化访客聊天
|
||||
const initGuestChat = async () => {
|
||||
try {
|
||||
await getOrCreateGuestUser()
|
||||
await fetchConversations()
|
||||
console.log('访客聊天初始化成功')
|
||||
} catch (error) {
|
||||
console.error('初始化访客聊天失败:', error)
|
||||
// 确保有基本的状态
|
||||
if (!guestUserInfo.value) {
|
||||
guestUserInfo.value = {
|
||||
id: `guest_${Date.now()}`,
|
||||
name: '访客用户',
|
||||
isGuest: true,
|
||||
createTime: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
if (!conversations.value) {
|
||||
conversations.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
currentConversation,
|
||||
messages,
|
||||
loading,
|
||||
typing,
|
||||
guestUserInfo,
|
||||
hasConversations,
|
||||
currentConversationId,
|
||||
getOrCreateGuestUser,
|
||||
fetchConversations,
|
||||
sendMessage,
|
||||
fetchMessages,
|
||||
switchConversation,
|
||||
clearCurrentConversation,
|
||||
endConversation,
|
||||
createNewConversation,
|
||||
initGuestChat
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const userInfo = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
avatar: ''
|
||||
})
|
||||
|
||||
const isLoggedIn = ref(false)
|
||||
|
||||
// 初始化用户信息
|
||||
const initUser = () => {
|
||||
try {
|
||||
// 从localStorage获取用户信息
|
||||
const savedUser = localStorage.getItem('emotion_museum_user')
|
||||
if (savedUser) {
|
||||
try {
|
||||
const user = JSON.parse(savedUser)
|
||||
userInfo.value = user
|
||||
// 只有非访客用户才算真正登录
|
||||
isLoggedIn.value = !user.isGuest
|
||||
console.log('用户信息已加载:', user.name || '访客用户')
|
||||
} catch (parseError) {
|
||||
console.warn('解析用户信息失败,使用访客模式:', parseError)
|
||||
// 清除无效数据
|
||||
localStorage.removeItem('emotion_museum_user')
|
||||
setGuestMode()
|
||||
}
|
||||
} else {
|
||||
// 访客模式 - 不设置用户信息,保持未登录状态
|
||||
setGuestMode()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化用户信息失败:', error)
|
||||
setGuestMode()
|
||||
}
|
||||
}
|
||||
|
||||
// 设置访客模式
|
||||
const setGuestMode = () => {
|
||||
userInfo.value = {
|
||||
id: `guest_${Date.now()}`,
|
||||
name: '访客用户',
|
||||
avatar: '',
|
||||
isGuest: true
|
||||
}
|
||||
isLoggedIn.value = false
|
||||
console.log('已切换到访客模式')
|
||||
}
|
||||
|
||||
// 设置用户信息
|
||||
const setUser = (user) => {
|
||||
userInfo.value = {
|
||||
...user,
|
||||
isGuest: false
|
||||
}
|
||||
isLoggedIn.value = true
|
||||
localStorage.setItem('emotion_museum_user', JSON.stringify(userInfo.value))
|
||||
}
|
||||
|
||||
// 清除用户信息
|
||||
const clearUser = () => {
|
||||
userInfo.value = {
|
||||
id: '',
|
||||
name: '',
|
||||
avatar: ''
|
||||
}
|
||||
isLoggedIn.value = false
|
||||
localStorage.removeItem('emotion_museum_user')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
|
||||
// 登出
|
||||
const logout = async () => {
|
||||
try {
|
||||
// 如果是登录用户,调用后端登出接口
|
||||
if (isLoggedIn.value && userInfo.value.id) {
|
||||
const { userApi } = await import('@/api/user')
|
||||
await userApi.logout(userInfo.value.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('登出接口调用失败:', error)
|
||||
} finally {
|
||||
// 清除本地状态
|
||||
clearUser()
|
||||
// 切换到访客模式
|
||||
setGuestMode()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
isLoggedIn,
|
||||
initUser,
|
||||
setUser,
|
||||
clearUser,
|
||||
setGuestMode,
|
||||
logout
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,269 @@
|
||||
// 全局样式变量
|
||||
:root {
|
||||
// 主题色彩
|
||||
--primary-color: #667eea;
|
||||
--primary-light: #8fa4f3;
|
||||
--primary-dark: #4c63d2;
|
||||
--secondary-color: #764ba2;
|
||||
--accent-color: #f093fb;
|
||||
|
||||
// 渐变色
|
||||
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--gradient-secondary: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
--gradient-success: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
--gradient-warning: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
|
||||
|
||||
// 文字颜色
|
||||
--text-primary: #2c3e50;
|
||||
--text-secondary: #7f8c8d;
|
||||
--text-light: #bdc3c7;
|
||||
--text-white: #ffffff;
|
||||
|
||||
// 背景色
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--bg-dark: #2c3e50;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.5);
|
||||
|
||||
// 边框和阴影
|
||||
--border-color: #e9ecef;
|
||||
--border-radius: 12px;
|
||||
--border-radius-small: 8px;
|
||||
--border-radius-large: 16px;
|
||||
--box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
--box-shadow-hover: 0 8px 30px rgba(0, 0, 0, 0.15);
|
||||
|
||||
// 间距
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--spacing-xxl: 48px;
|
||||
}
|
||||
|
||||
// 重置样式
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
// 通用工具类
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overflow-auto {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 渐变文字
|
||||
.gradient-text {
|
||||
background: var(--gradient-primary);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 玻璃态效果
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
// 卡片样式
|
||||
.card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow);
|
||||
padding: var(--spacing-lg);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--box-shadow-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮样式增强
|
||||
.ant-btn {
|
||||
border-radius: var(--border-radius-small);
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.gradient-btn {
|
||||
background: var(--gradient-primary);
|
||||
border: none;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: var(--gradient-primary);
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 输入框样式增强
|
||||
.ant-input {
|
||||
border-radius: var(--border-radius-small);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// 消息气泡样式
|
||||
.message-bubble {
|
||||
max-width: 70%;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: var(--spacing-md);
|
||||
word-wrap: break-word;
|
||||
|
||||
&.user {
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
margin-left: auto;
|
||||
border-bottom-right-radius: var(--spacing-xs);
|
||||
}
|
||||
|
||||
&.assistant {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom-left-radius: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.message-bubble {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
// 动画类
|
||||
.bounce-in {
|
||||
animation: bounceIn 0.6s ease;
|
||||
}
|
||||
|
||||
@keyframes bounceIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.6s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载动画
|
||||
.loading-dots {
|
||||
display: inline-block;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
animation: dots 1.5s steps(5, end) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0%, 20% {
|
||||
color: rgba(0, 0, 0, 0);
|
||||
text-shadow: 0.25em 0 0 rgba(0, 0, 0, 0), 0.5em 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
40% {
|
||||
color: black;
|
||||
text-shadow: 0.25em 0 0 rgba(0, 0, 0, 0), 0.5em 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
60% {
|
||||
text-shadow: 0.25em 0 0 black, 0.5em 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
80%, 100% {
|
||||
text-shadow: 0.25em 0 0 black, 0.5em 0 0 black;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { userApi } from '@/api/user'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
/**
|
||||
* 认证工具类
|
||||
*/
|
||||
export class AuthUtils {
|
||||
static TOKEN_KEY = 'token'
|
||||
static REFRESH_TOKEN_KEY = 'refreshToken'
|
||||
static USER_KEY = 'emotion_museum_user'
|
||||
|
||||
/**
|
||||
* 获取token
|
||||
*/
|
||||
static getToken() {
|
||||
return localStorage.getItem(this.TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取刷新token
|
||||
*/
|
||||
static getRefreshToken() {
|
||||
return localStorage.getItem(this.REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置token
|
||||
*/
|
||||
static setToken(token, refreshToken) {
|
||||
localStorage.setItem(this.TOKEN_KEY, token)
|
||||
if (refreshToken) {
|
||||
localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除token
|
||||
*/
|
||||
static clearTokens() {
|
||||
localStorage.removeItem(this.TOKEN_KEY)
|
||||
localStorage.removeItem(this.REFRESH_TOKEN_KEY)
|
||||
localStorage.removeItem(this.USER_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否存在
|
||||
*/
|
||||
static hasToken() {
|
||||
return !!this.getToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否即将过期(提前5分钟刷新)
|
||||
*/
|
||||
static isTokenExpiringSoon(token) {
|
||||
if (!token) return true
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
const exp = payload.exp * 1000 // 转换为毫秒
|
||||
const now = Date.now()
|
||||
const fiveMinutes = 5 * 60 * 1000
|
||||
|
||||
return (exp - now) < fiveMinutes
|
||||
} catch (error) {
|
||||
console.warn('解析token失败:', error)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新token
|
||||
*/
|
||||
static async refreshToken() {
|
||||
const refreshToken = this.getRefreshToken()
|
||||
if (!refreshToken) {
|
||||
throw new Error('没有刷新token')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await userApi.refreshToken(refreshToken)
|
||||
if (response.success) {
|
||||
const { accessToken, refreshToken: newRefreshToken } = response.data
|
||||
this.setToken(accessToken, newRefreshToken)
|
||||
return accessToken
|
||||
} else {
|
||||
throw new Error(response.message || '刷新token失败')
|
||||
}
|
||||
} catch (error) {
|
||||
// 刷新失败,清除所有token
|
||||
this.clearTokens()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动刷新token(如果需要)
|
||||
*/
|
||||
static async autoRefreshToken() {
|
||||
const token = this.getToken()
|
||||
if (!token) return null
|
||||
|
||||
if (this.isTokenExpiringSoon(token)) {
|
||||
try {
|
||||
return await this.refreshToken()
|
||||
} catch (error) {
|
||||
console.warn('自动刷新token失败:', error)
|
||||
// 跳转到登录页
|
||||
const userStore = useUserStore()
|
||||
userStore.clearUser()
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
static async logout() {
|
||||
const userStore = useUserStore()
|
||||
await userStore.logout()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置token自动刷新定时器
|
||||
*/
|
||||
export function setupTokenRefreshTimer() {
|
||||
// 每5分钟检查一次token是否需要刷新
|
||||
setInterval(async () => {
|
||||
if (AuthUtils.hasToken()) {
|
||||
await AuthUtils.autoRefreshToken()
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 环境配置使用示例
|
||||
*
|
||||
* 这个文件展示了如何在项目中使用环境变量配置
|
||||
*/
|
||||
|
||||
import { ENV_CONFIG, isDev, isTest, isProd, debugLog, getApiUrl, printEnvInfo } from '@/config/env'
|
||||
|
||||
// 示例1: 基础环境判断
|
||||
export const exampleEnvironmentCheck = () => {
|
||||
if (isDev()) {
|
||||
console.log('当前是开发环境')
|
||||
// 开发环境特有逻辑
|
||||
} else if (isTest()) {
|
||||
console.log('当前是测试环境')
|
||||
// 测试环境特有逻辑
|
||||
} else if (isProd()) {
|
||||
console.log('当前是生产环境')
|
||||
// 生产环境特有逻辑
|
||||
}
|
||||
}
|
||||
|
||||
// 示例2: 使用调试日志
|
||||
export const exampleDebugLog = () => {
|
||||
debugLog('这条日志只在调试模式下显示')
|
||||
debugLog('用户操作:', { action: 'click', target: 'button' })
|
||||
}
|
||||
|
||||
// 示例3: 获取API地址
|
||||
export const exampleApiCall = async () => {
|
||||
const userApiUrl = getApiUrl('/user/profile')
|
||||
debugLog('API地址:', userApiUrl)
|
||||
|
||||
// 使用fetch或axios调用API
|
||||
try {
|
||||
const response = await fetch(userApiUrl)
|
||||
const data = await response.json()
|
||||
debugLog('API响应:', data)
|
||||
return data
|
||||
} catch (error) {
|
||||
debugLog('API错误:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 示例4: 根据环境配置不同的行为
|
||||
export const exampleConditionalBehavior = () => {
|
||||
// 根据环境显示不同的标题
|
||||
document.title = ENV_CONFIG.APP_TITLE
|
||||
|
||||
// 在开发环境启用额外的调试工具
|
||||
if (ENV_CONFIG.DEBUG_MODE) {
|
||||
// 启用Vue DevTools
|
||||
window.__VUE_DEVTOOLS_GLOBAL_HOOK__ = window.__VUE_DEVTOOLS_GLOBAL_HOOK__ || {}
|
||||
|
||||
// 打印环境信息
|
||||
printEnvInfo()
|
||||
}
|
||||
|
||||
// 根据环境配置不同的错误处理
|
||||
if (isProd()) {
|
||||
// 生产环境:静默处理错误,发送到监控系统
|
||||
window.addEventListener('error', (event) => {
|
||||
// 发送错误到监控系统
|
||||
console.error('生产环境错误:', event.error)
|
||||
})
|
||||
} else {
|
||||
// 开发/测试环境:显示详细错误信息
|
||||
window.addEventListener('error', (event) => {
|
||||
debugLog('开发环境错误:', event.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 示例5: 环境特定的配置
|
||||
export const getEnvironmentSpecificConfig = () => {
|
||||
const config = {
|
||||
// 基础配置
|
||||
apiTimeout: ENV_CONFIG.API_TIMEOUT,
|
||||
debugMode: ENV_CONFIG.DEBUG_MODE,
|
||||
|
||||
// 环境特定配置
|
||||
enableAnalytics: isProd(), // 只在生产环境启用分析
|
||||
enableMocking: ENV_CONFIG.MOCK_DATA, // 根据环境变量决定是否启用模拟数据
|
||||
logLevel: isDev() ? 'debug' : isProd() ? 'error' : 'info',
|
||||
|
||||
// 功能开关
|
||||
features: {
|
||||
newFeature: isDev() || isTest(), // 新功能只在开发和测试环境启用
|
||||
betaFeature: !isProd(), // Beta功能在非生产环境启用
|
||||
experimentalFeature: isDev() // 实验性功能只在开发环境启用
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('环境特定配置:', config)
|
||||
return config
|
||||
}
|
||||
|
||||
// 导出所有示例函数
|
||||
export default {
|
||||
exampleEnvironmentCheck,
|
||||
exampleDebugLog,
|
||||
exampleApiCall,
|
||||
exampleConditionalBehavior,
|
||||
getEnvironmentSpecificConfig
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
// 配置 dayjs
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
* @param {string|Date} time - 时间
|
||||
* @param {string} format - 格式化模板
|
||||
* @returns {string} 格式化后的时间字符串
|
||||
*/
|
||||
export function formatTime(time, format = 'YYYY-MM-DD HH:mm:ss') {
|
||||
if (!time) return ''
|
||||
|
||||
const now = dayjs()
|
||||
const target = dayjs(time)
|
||||
const diffInHours = now.diff(target, 'hour')
|
||||
const diffInDays = now.diff(target, 'day')
|
||||
|
||||
// 如果是今天
|
||||
if (diffInDays === 0) {
|
||||
if (diffInHours === 0) {
|
||||
return target.fromNow() // 几分钟前
|
||||
} else {
|
||||
return target.format('HH:mm') // 今天的时间
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是昨天
|
||||
if (diffInDays === 1) {
|
||||
return `昨天 ${target.format('HH:mm')}`
|
||||
}
|
||||
|
||||
// 如果是本周
|
||||
if (diffInDays < 7) {
|
||||
return target.format('dddd HH:mm')
|
||||
}
|
||||
|
||||
// 如果是今年
|
||||
if (target.year() === now.year()) {
|
||||
return target.format('MM-DD HH:mm')
|
||||
}
|
||||
|
||||
// 其他情况使用完整格式
|
||||
return target.format(format)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化相对时间
|
||||
* @param {string|Date} time - 时间
|
||||
* @returns {string} 相对时间字符串
|
||||
*/
|
||||
export function formatRelativeTime(time) {
|
||||
if (!time) return ''
|
||||
return dayjs(time).fromNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化消息内容
|
||||
* @param {string} content - 消息内容
|
||||
* @returns {string} 格式化后的HTML内容
|
||||
*/
|
||||
export function formatMessage(content) {
|
||||
if (!content) return ''
|
||||
|
||||
// 转义HTML特殊字符
|
||||
const escaped = content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
// 处理换行
|
||||
let formatted = escaped.replace(/\n/g, '<br>')
|
||||
|
||||
// 处理链接
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||
formatted = formatted.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
|
||||
// 处理邮箱
|
||||
const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g
|
||||
formatted = formatted.replace(emailRegex, '<a href="mailto:$1">$1</a>')
|
||||
|
||||
// 处理电话号码
|
||||
const phoneRegex = /(\d{3}-\d{4}-\d{4}|\d{11})/g
|
||||
formatted = formatted.replace(phoneRegex, '<a href="tel:$1">$1</a>')
|
||||
|
||||
// 处理表情符号(简单的文本表情)
|
||||
const emoticons = {
|
||||
':)': '😊',
|
||||
':-)': '😊',
|
||||
':(': '😢',
|
||||
':-(': '😢',
|
||||
':D': '😃',
|
||||
':-D': '😃',
|
||||
':P': '😛',
|
||||
':-P': '😛',
|
||||
';)': '😉',
|
||||
';-)': '😉',
|
||||
':o': '😮',
|
||||
':-o': '😮',
|
||||
':|': '😐',
|
||||
':-|': '😐',
|
||||
'<3': '❤️',
|
||||
'</3': '💔'
|
||||
}
|
||||
|
||||
Object.entries(emoticons).forEach(([text, emoji]) => {
|
||||
const regex = new RegExp(escapeRegExp(text), 'g')
|
||||
formatted = formatted.replace(regex, emoji)
|
||||
})
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义正则表达式特殊字符
|
||||
* @param {string} string - 要转义的字符串
|
||||
* @returns {string} 转义后的字符串
|
||||
*/
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} bytes - 字节数
|
||||
* @returns {string} 格式化后的文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字
|
||||
* @param {number} num - 数字
|
||||
* @param {number} precision - 精度
|
||||
* @returns {string} 格式化后的数字
|
||||
*/
|
||||
export function formatNumber(num, precision = 0) {
|
||||
if (typeof num !== 'number') return '0'
|
||||
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(precision) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(precision) + 'K'
|
||||
} else {
|
||||
return num.toFixed(precision)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化百分比
|
||||
* @param {number} value - 值
|
||||
* @param {number} total - 总数
|
||||
* @param {number} precision - 精度
|
||||
* @returns {string} 百分比字符串
|
||||
*/
|
||||
export function formatPercentage(value, total, precision = 1) {
|
||||
if (total === 0) return '0%'
|
||||
return ((value / total) * 100).toFixed(precision) + '%'
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断文本
|
||||
* @param {string} text - 文本
|
||||
* @param {number} maxLength - 最大长度
|
||||
* @param {string} suffix - 后缀
|
||||
* @returns {string} 截断后的文本
|
||||
*/
|
||||
export function truncateText(text, maxLength = 100, suffix = '...') {
|
||||
if (!text || text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength - suffix.length) + suffix
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化持续时间
|
||||
* @param {number} seconds - 秒数
|
||||
* @returns {string} 格式化后的持续时间
|
||||
*/
|
||||
export function formatDuration(seconds) {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}秒`
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.round(seconds % 60)
|
||||
return remainingSeconds > 0 ? `${minutes}分${remainingSeconds}秒` : `${minutes}分钟`
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化货币
|
||||
* @param {number} amount - 金额
|
||||
* @param {string} currency - 货币符号
|
||||
* @returns {string} 格式化后的货币
|
||||
*/
|
||||
export function formatCurrency(amount, currency = '¥') {
|
||||
if (typeof amount !== 'number') return `${currency}0.00`
|
||||
return `${currency}${amount.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱格式
|
||||
* @param {string} email - 邮箱地址
|
||||
* @returns {boolean} 是否有效
|
||||
*/
|
||||
export function isValidEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号格式
|
||||
* @param {string} phone - 手机号
|
||||
* @returns {boolean} 是否有效
|
||||
*/
|
||||
export function isValidPhone(phone) {
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
return phoneRegex.test(phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机ID
|
||||
* @param {number} length - 长度
|
||||
* @returns {string} 随机ID
|
||||
*/
|
||||
export function generateId(length = 8) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝对象
|
||||
* @param {any} obj - 要拷贝的对象
|
||||
* @returns {any} 拷贝后的对象
|
||||
*/
|
||||
export function deepClone(obj) {
|
||||
if (obj === null || typeof obj !== 'object') return obj
|
||||
if (obj instanceof Date) return new Date(obj.getTime())
|
||||
if (obj instanceof Array) return obj.map(item => deepClone(item))
|
||||
if (typeof obj === 'object') {
|
||||
const clonedObj = {}
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
clonedObj[key] = deepClone(obj[key])
|
||||
}
|
||||
}
|
||||
return clonedObj
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param {Function} func - 要防抖的函数
|
||||
* @param {number} wait - 等待时间
|
||||
* @returns {Function} 防抖后的函数
|
||||
*/
|
||||
export function debounce(func, wait) {
|
||||
let timeout
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout)
|
||||
func(...args)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param {Function} func - 要节流的函数
|
||||
* @param {number} limit - 限制时间
|
||||
* @returns {Function} 节流后的函数
|
||||
*/
|
||||
export function throttle(func, limit) {
|
||||
let inThrottle
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args)
|
||||
inThrottle = true
|
||||
setTimeout(() => inThrottle = false, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
<template>
|
||||
<div class="analysis-page">
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header glass">
|
||||
<div class="header-content">
|
||||
<div class="page-title">
|
||||
<router-link to="/" class="back-btn">
|
||||
<ArrowLeftOutlined />
|
||||
</router-link>
|
||||
<h1 class="gradient-text">情绪分析</h1>
|
||||
<span class="subtitle">深入了解您的情绪状态和心理健康</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="page-content">
|
||||
<div class="content-container">
|
||||
<!-- 快速分析 -->
|
||||
<div class="quick-analysis-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">快速情绪分析</h2>
|
||||
<p class="section-description">输入您想要分析的文本,获得即时的情绪分析结果</p>
|
||||
</div>
|
||||
|
||||
<div class="analysis-form card">
|
||||
<a-textarea
|
||||
v-model:value="analysisText"
|
||||
placeholder="请输入您想要分析的文本内容,比如今天的心情、遇到的事情等..."
|
||||
:auto-size="{ minRows: 4, maxRows: 8 }"
|
||||
class="analysis-input"
|
||||
/>
|
||||
|
||||
<div class="form-actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
class="gradient-btn"
|
||||
size="large"
|
||||
@click="analyzeText"
|
||||
:loading="analyzing"
|
||||
:disabled="!analysisText.trim()"
|
||||
>
|
||||
<SearchOutlined />
|
||||
开始分析
|
||||
</a-button>
|
||||
|
||||
<a-button
|
||||
size="large"
|
||||
@click="clearAnalysis"
|
||||
:disabled="!analysisText.trim()"
|
||||
>
|
||||
<ClearOutlined />
|
||||
清空
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分析结果 -->
|
||||
<div class="analysis-result card" v-if="currentAnalysis">
|
||||
<div class="result-header">
|
||||
<h3 class="result-title">
|
||||
<HeartOutlined />
|
||||
分析结果
|
||||
</h3>
|
||||
<div class="result-time">{{ formatTime(currentAnalysis.analysisTime) }}</div>
|
||||
</div>
|
||||
|
||||
<EmotionAnalysis :analysis="currentAnalysis" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史分析记录 -->
|
||||
<div class="history-analysis-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">历史分析记录</h2>
|
||||
<p class="section-description">查看您过往的情绪分析记录和趋势</p>
|
||||
</div>
|
||||
|
||||
<div class="analysis-history card">
|
||||
<a-spin :spinning="loadingHistory">
|
||||
<div class="history-list" v-if="analysisHistory.length > 0">
|
||||
<div
|
||||
class="history-item"
|
||||
v-for="analysis in analysisHistory"
|
||||
:key="analysis.id"
|
||||
@click="viewAnalysis(analysis)"
|
||||
>
|
||||
<div class="item-header">
|
||||
<div class="item-emotion">
|
||||
<a-tag
|
||||
:color="getEmotionColor(analysis.primaryEmotion)"
|
||||
class="emotion-tag"
|
||||
>
|
||||
{{ getEmotionText(analysis.primaryEmotion) }}
|
||||
</a-tag>
|
||||
<span class="emotion-intensity" v-if="analysis.intensity">
|
||||
{{ Math.round(analysis.intensity * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-time">{{ formatTime(analysis.analysisTime) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="item-content">
|
||||
<div class="item-text">{{ analysis.text }}</div>
|
||||
<div class="item-polarity">
|
||||
<a-tag
|
||||
:color="getPolarityColor(analysis.polarity)"
|
||||
size="small"
|
||||
>
|
||||
{{ getPolarityText(analysis.polarity) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-history" v-else>
|
||||
<BarChartOutlined class="empty-icon" />
|
||||
<p>暂无分析记录</p>
|
||||
<p class="empty-tip">开始您的第一次情绪分析吧!</p>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 情绪趋势图表 -->
|
||||
<div class="emotion-trends-section" v-if="analysisHistory.length > 0">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">情绪趋势</h2>
|
||||
<p class="section-description">查看您的情绪变化趋势和模式</p>
|
||||
</div>
|
||||
|
||||
<div class="trends-chart card">
|
||||
<EmotionTrends :data="analysisHistory" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 分析详情模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showAnalysisDetail"
|
||||
:title="`情绪分析详情 - ${selectedAnalysis?.primaryEmotion || ''}`"
|
||||
width="600px"
|
||||
:footer="null"
|
||||
>
|
||||
<div class="analysis-detail" v-if="selectedAnalysis">
|
||||
<div class="detail-text">
|
||||
<h4>分析文本</h4>
|
||||
<p>{{ selectedAnalysis.text }}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-analysis">
|
||||
<EmotionAnalysis :analysis="selectedAnalysis" />
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
SearchOutlined,
|
||||
ClearOutlined,
|
||||
HeartOutlined,
|
||||
BarChartOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { chatApi } from '@/api/chat'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import EmotionAnalysis from '@/components/EmotionAnalysis.vue'
|
||||
import EmotionTrends from '@/components/EmotionTrends.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 响应式数据
|
||||
const analysisText = ref('')
|
||||
const analyzing = ref(false)
|
||||
const currentAnalysis = ref(null)
|
||||
const analysisHistory = ref([])
|
||||
const loadingHistory = ref(false)
|
||||
const showAnalysisDetail = ref(false)
|
||||
const selectedAnalysis = ref(null)
|
||||
|
||||
// 情绪映射
|
||||
const emotionMap = {
|
||||
joy: '喜悦',
|
||||
sadness: '悲伤',
|
||||
anger: '愤怒',
|
||||
fear: '恐惧',
|
||||
surprise: '惊讶',
|
||||
disgust: '厌恶',
|
||||
trust: '信任',
|
||||
anticipation: '期待',
|
||||
anxiety: '焦虑',
|
||||
depression: '抑郁',
|
||||
excitement: '兴奋',
|
||||
calm: '平静',
|
||||
stress: '压力',
|
||||
happiness: '快乐',
|
||||
worry: '担忧',
|
||||
relief: '放松',
|
||||
frustration: '沮丧',
|
||||
hope: '希望',
|
||||
love: '爱',
|
||||
hate: '恨'
|
||||
}
|
||||
|
||||
const polarityMap = {
|
||||
positive: '积极',
|
||||
negative: '消极',
|
||||
neutral: '中性'
|
||||
}
|
||||
|
||||
// 方法
|
||||
const getEmotionText = (emotion) => {
|
||||
return emotionMap[emotion] || emotion
|
||||
}
|
||||
|
||||
const getPolarityText = (polarity) => {
|
||||
return polarityMap[polarity] || polarity
|
||||
}
|
||||
|
||||
const getEmotionColor = (emotion) => {
|
||||
const colorMap = {
|
||||
joy: 'gold',
|
||||
happiness: 'gold',
|
||||
excitement: 'orange',
|
||||
love: 'magenta',
|
||||
trust: 'blue',
|
||||
hope: 'cyan',
|
||||
calm: 'green',
|
||||
relief: 'green',
|
||||
sadness: 'blue',
|
||||
depression: 'purple',
|
||||
worry: 'orange',
|
||||
anxiety: 'orange',
|
||||
stress: 'red',
|
||||
anger: 'red',
|
||||
frustration: 'red',
|
||||
hate: 'red',
|
||||
fear: 'volcano',
|
||||
surprise: 'lime',
|
||||
anticipation: 'geekblue',
|
||||
disgust: 'default'
|
||||
}
|
||||
return colorMap[emotion] || 'default'
|
||||
}
|
||||
|
||||
const getPolarityColor = (polarity) => {
|
||||
const colorMap = {
|
||||
positive: 'success',
|
||||
negative: 'error',
|
||||
neutral: 'default'
|
||||
}
|
||||
return colorMap[polarity] || 'default'
|
||||
}
|
||||
|
||||
const analyzeText = async () => {
|
||||
if (!analysisText.value.trim()) return
|
||||
|
||||
try {
|
||||
analyzing.value = true
|
||||
|
||||
const response = await chatApi.analyzeEmotion({
|
||||
userId: userStore.userInfo.id,
|
||||
text: analysisText.value.trim(),
|
||||
analysisType: 'detailed'
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
currentAnalysis.value = {
|
||||
...response.data,
|
||||
analysisTime: new Date(),
|
||||
text: analysisText.value.trim()
|
||||
}
|
||||
|
||||
// 添加到历史记录
|
||||
analysisHistory.value.unshift(currentAnalysis.value)
|
||||
|
||||
message.success('分析完成')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('情绪分析失败:', error)
|
||||
message.error('分析失败,请重试')
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearAnalysis = () => {
|
||||
analysisText.value = ''
|
||||
currentAnalysis.value = null
|
||||
}
|
||||
|
||||
const viewAnalysis = (analysis) => {
|
||||
selectedAnalysis.value = analysis
|
||||
showAnalysisDetail.value = true
|
||||
}
|
||||
|
||||
const loadAnalysisHistory = async () => {
|
||||
try {
|
||||
loadingHistory.value = true
|
||||
// 这里应该调用获取历史分析记录的API
|
||||
// 暂时使用模拟数据
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 模拟历史数据
|
||||
analysisHistory.value = [
|
||||
{
|
||||
id: '1',
|
||||
text: '今天工作很顺利,心情不错',
|
||||
primaryEmotion: 'happiness',
|
||||
intensity: 0.8,
|
||||
polarity: 'positive',
|
||||
analysisTime: new Date(Date.now() - 86400000),
|
||||
confidence: 0.9
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
text: '有点担心明天的面试',
|
||||
primaryEmotion: 'anxiety',
|
||||
intensity: 0.6,
|
||||
polarity: 'negative',
|
||||
analysisTime: new Date(Date.now() - 172800000),
|
||||
confidence: 0.85
|
||||
}
|
||||
]
|
||||
} catch (error) {
|
||||
console.error('加载历史记录失败:', error)
|
||||
} finally {
|
||||
loadingHistory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载
|
||||
onMounted(() => {
|
||||
loadAnalysisHistory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.analysis-page {
|
||||
min-height: 100vh;
|
||||
background: var(--gradient-primary);
|
||||
|
||||
.page-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: var(--spacing-lg) 0;
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
|
||||
.back-btn {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 20px;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-left: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: var(--spacing-xl) var(--spacing-lg);
|
||||
|
||||
.content-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.quick-analysis-section,
|
||||
.history-analysis-section,
|
||||
.emotion-trends-section {
|
||||
margin-bottom: var(--spacing-xxl);
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
color: white;
|
||||
|
||||
.section-title {
|
||||
font-size: 24px;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.analysis-form {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
|
||||
.analysis-input {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.analysis-result {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: var(--spacing-xl);
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.result-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.result-time {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.analysis-history {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: var(--spacing-xl);
|
||||
|
||||
.history-list {
|
||||
.history-item {
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: var(--spacing-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
|
||||
.item-emotion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
|
||||
.emotion-intensity {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.item-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.item-content {
|
||||
.item-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-polarity {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-history {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xxl);
|
||||
color: var(--text-secondary);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
font-size: 14px;
|
||||
margin-top: var(--spacing-xs);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trends-chart {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
}
|
||||
|
||||
.analysis-detail {
|
||||
.detail-text {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
|
||||
h4 {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
background: var(--bg-secondary);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.analysis-page {
|
||||
.page-header {
|
||||
.header-content {
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
.subtitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: var(--spacing-lg) var(--spacing-md);
|
||||
|
||||
.analysis-form,
|
||||
.analysis-result,
|
||||
.analysis-history,
|
||||
.trends-chart {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
|
||||
.ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="analysis-simple">
|
||||
<div class="page-header">
|
||||
<h1>情绪分析</h1>
|
||||
<a-button @click="goBack">返回首页</a-button>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<div class="welcome-message">
|
||||
<h2>情绪分析功能</h2>
|
||||
<p>这里将提供强大的情绪分析功能,帮助您了解自己的情绪状态。</p>
|
||||
|
||||
<div class="test-buttons">
|
||||
<a-button type="primary" @click="testFunction">测试按钮</a-button>
|
||||
<a-button @click="$router.push('/chat')">开始对话</a-button>
|
||||
<a-button @click="$router.push('/history')">查看历史</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const testFunction = () => {
|
||||
alert('情绪分析页面测试按钮工作正常!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.analysis-simple {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
|
||||
.welcome-message {
|
||||
h2 {
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
margin-bottom: 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.test-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,826 @@
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo" v-if="!sidebarCollapsed">
|
||||
<h2 class="gradient-text">情绪博物馆</h2>
|
||||
</div>
|
||||
<a-button
|
||||
type="text"
|
||||
class="collapse-btn"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<MenuOutlined v-if="sidebarCollapsed" />
|
||||
<MenuFoldOutlined v-else />
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content" v-if="!sidebarCollapsed">
|
||||
<!-- 新建对话按钮 -->
|
||||
<a-button
|
||||
type="primary"
|
||||
class="new-chat-btn gradient-btn"
|
||||
block
|
||||
@click="createNewChat"
|
||||
:loading="activeStore.loading"
|
||||
>
|
||||
<PlusOutlined />
|
||||
新建对话
|
||||
</a-button>
|
||||
|
||||
<!-- 对话列表 -->
|
||||
<div class="conversations-list">
|
||||
<div class="list-header">
|
||||
<span class="list-title">最近对话</span>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="refreshConversations"
|
||||
:loading="activeStore.loading"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="conversations" v-if="activeStore.hasConversations">
|
||||
<div
|
||||
class="conversation-item"
|
||||
:class="{ active: conversation.conversationId === activeStore.currentConversationId }"
|
||||
v-for="conversation in activeStore.conversations"
|
||||
:key="conversation.conversationId"
|
||||
@click="switchConversation(conversation)"
|
||||
>
|
||||
<div class="conversation-info">
|
||||
<div class="conversation-title">{{ conversation.title }}</div>
|
||||
<div class="conversation-time">{{ formatTime(conversation.updateTime) }}</div>
|
||||
</div>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="text" size="small" class="more-btn">
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="deleteConversation(conversation.conversationId)">
|
||||
<DeleteOutlined />
|
||||
删除对话
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-conversations" v-else>
|
||||
<CommentOutlined class="empty-icon" />
|
||||
<p>暂无对话记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div class="user-info" v-if="!sidebarCollapsed">
|
||||
<div class="user-avatar">
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<div class="user-name">{{ userStore.userInfo.name }}</div>
|
||||
<div class="user-status">在线</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主聊天区域 -->
|
||||
<main class="chat-main">
|
||||
<!-- 聊天头部 -->
|
||||
<header class="chat-header" v-if="chatStore.currentConversation">
|
||||
<div class="chat-info">
|
||||
<h3 class="chat-title">{{ chatStore.currentConversation.title }}</h3>
|
||||
<span class="chat-status">{{ getChatStatus() }}</span>
|
||||
</div>
|
||||
<div class="chat-actions">
|
||||
<a-button type="text" @click="showHistory = true">
|
||||
<HistoryOutlined />
|
||||
历史记录
|
||||
</a-button>
|
||||
<a-button type="text" @click="endCurrentChat">
|
||||
<PoweroffOutlined />
|
||||
结束对话
|
||||
</a-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 消息区域 -->
|
||||
<div class="messages-container" ref="messagesContainer">
|
||||
<!-- 欢迎界面 -->
|
||||
<div class="welcome-screen" v-if="!activeStore.currentConversation">
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-icon">
|
||||
<RobotOutlined />
|
||||
</div>
|
||||
<h2 class="welcome-title">欢迎使用AI心理健康助手</h2>
|
||||
<p class="welcome-description">
|
||||
我是您的专属AI助手,可以为您提供情绪支持、心理分析和个性化建议。
|
||||
让我们开始一段温暖的对话吧!
|
||||
</p>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="gradient-btn"
|
||||
@click="createNewChat"
|
||||
>
|
||||
<MessageOutlined />
|
||||
开始对话
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="messages-list" v-else>
|
||||
<div
|
||||
class="message-item"
|
||||
:class="message.sender"
|
||||
v-for="message in activeStore.messages"
|
||||
:key="message.id"
|
||||
>
|
||||
<div class="message-avatar">
|
||||
<UserOutlined v-if="message.sender === 'user'" />
|
||||
<RobotOutlined v-else />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-bubble">
|
||||
<div class="message-text" v-html="formatMessage(message.content)"></div>
|
||||
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
|
||||
</div>
|
||||
<!-- 暂时隐藏情绪分析结果 -->
|
||||
<!-- <div class="emotion-analysis" v-if="message.emotionAnalysis">
|
||||
<EmotionAnalysis :analysis="message.emotionAnalysis" />
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI正在输入 -->
|
||||
<div class="message-item assistant" v-if="activeStore.typing">
|
||||
<div class="message-avatar">
|
||||
<RobotOutlined />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-bubble typing">
|
||||
<div class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-area" v-if="activeStore.currentConversation">
|
||||
<div class="input-container">
|
||||
<a-textarea
|
||||
v-model:value="inputMessage"
|
||||
:placeholder="inputPlaceholder"
|
||||
:auto-size="{ minRows: 1, maxRows: 4 }"
|
||||
@keydown="handleKeyDown"
|
||||
:disabled="activeStore.typing"
|
||||
class="message-input"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<!-- 暂时隐藏情绪分析按钮 -->
|
||||
<!-- <a-tooltip title="情绪分析">
|
||||
<a-button
|
||||
type="text"
|
||||
:class="{ active: enableEmotionAnalysis }"
|
||||
@click="enableEmotionAnalysis = !enableEmotionAnalysis"
|
||||
>
|
||||
<HeartOutlined />
|
||||
</a-button>
|
||||
</a-tooltip> -->
|
||||
<a-button
|
||||
type="primary"
|
||||
class="send-btn gradient-btn"
|
||||
@click="sendMessage"
|
||||
:loading="activeStore.typing"
|
||||
:disabled="!inputMessage.trim()"
|
||||
>
|
||||
<SendOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 历史记录抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="showHistory"
|
||||
title="对话历史"
|
||||
placement="right"
|
||||
width="400"
|
||||
>
|
||||
<HistoryPanel />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
MenuOutlined,
|
||||
MenuFoldOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
MoreOutlined,
|
||||
DeleteOutlined,
|
||||
UserOutlined,
|
||||
RobotOutlined,
|
||||
MessageOutlined,
|
||||
SendOutlined,
|
||||
HeartOutlined,
|
||||
HistoryOutlined,
|
||||
PoweroffOutlined,
|
||||
CommentOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useGuestChatStore } from '@/stores/guestChat'
|
||||
import EmotionAnalysis from '@/components/EmotionAnalysis.vue'
|
||||
import HistoryPanel from '@/components/HistoryPanel.vue'
|
||||
import { formatTime, formatMessage } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const chatStore = useChatStore()
|
||||
const guestChatStore = useGuestChatStore()
|
||||
|
||||
// 判断是否为访客模式(没有登录用户)
|
||||
const isGuestMode = computed(() => !userStore.isLoggedIn)
|
||||
const activeStore = computed(() => isGuestMode.value ? guestChatStore : chatStore)
|
||||
|
||||
// 响应式数据
|
||||
const sidebarCollapsed = ref(false)
|
||||
const inputMessage = ref('')
|
||||
const enableEmotionAnalysis = ref(false) // 暂时禁用情绪分析
|
||||
const showHistory = ref(false)
|
||||
const messagesContainer = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const inputPlaceholder = computed(() => {
|
||||
return activeStore.value.typing ? 'AI正在思考中...' : '输入您想说的话...'
|
||||
})
|
||||
|
||||
// 方法
|
||||
const toggleSidebar = () => {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
const createNewChat = async () => {
|
||||
try {
|
||||
if (isGuestMode.value) {
|
||||
// 访客模式:清空当前会话,等待用户发送第一条消息
|
||||
guestChatStore.createNewConversation()
|
||||
message.success('准备开始新对话')
|
||||
} else {
|
||||
// 注册用户模式
|
||||
await chatStore.createConversation({
|
||||
userId: userStore.userInfo.id,
|
||||
title: `对话 ${new Date().toLocaleString()}`,
|
||||
type: 'emotion_chat',
|
||||
initialMessage: '您好,我想开始一段新的对话'
|
||||
})
|
||||
message.success('新对话创建成功')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建对话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshConversations = async () => {
|
||||
try {
|
||||
if (isGuestMode.value) {
|
||||
await guestChatStore.fetchConversations()
|
||||
} else {
|
||||
await chatStore.fetchConversations(userStore.userInfo.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新对话列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const switchConversation = async (conversation) => {
|
||||
try {
|
||||
if (isGuestMode.value) {
|
||||
await guestChatStore.switchConversation(conversation)
|
||||
} else {
|
||||
await chatStore.switchConversation(conversation)
|
||||
}
|
||||
scrollToBottom()
|
||||
} catch (error) {
|
||||
console.error('切换对话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteConversation = async (conversationId) => {
|
||||
try {
|
||||
if (isGuestMode.value) {
|
||||
await guestChatStore.endConversation(conversationId)
|
||||
} else {
|
||||
await chatStore.deleteConversation(conversationId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除对话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!inputMessage.value.trim()) return
|
||||
|
||||
const content = inputMessage.value.trim()
|
||||
inputMessage.value = ''
|
||||
|
||||
try {
|
||||
if (isGuestMode.value) {
|
||||
await guestChatStore.sendMessage(content)
|
||||
} else {
|
||||
await chatStore.sendMessage(content, enableEmotionAnalysis.value)
|
||||
}
|
||||
scrollToBottom()
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
const getChatStatus = () => {
|
||||
const store = activeStore.value
|
||||
if (store.typing) return 'AI正在输入...'
|
||||
if (store.currentConversation?.status === 'active') return '对话中'
|
||||
return '已结束'
|
||||
}
|
||||
|
||||
const endCurrentChat = async () => {
|
||||
const store = activeStore.value
|
||||
if (!store.currentConversation) return
|
||||
|
||||
try {
|
||||
if (isGuestMode.value) {
|
||||
await guestChatStore.endConversation(store.currentConversation.conversationId)
|
||||
} else {
|
||||
await chatStore.endConversation(store.currentConversation.conversationId)
|
||||
}
|
||||
message.success('对话已结束')
|
||||
} catch (error) {
|
||||
console.error('结束对话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 监听消息变化,自动滚动到底部
|
||||
watch(() => activeStore.value.messages.length, () => {
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
// 组件挂载
|
||||
onMounted(async () => {
|
||||
// 初始化访客聊天(如果是访客模式)
|
||||
if (isGuestMode.value) {
|
||||
await guestChatStore.initGuestChat()
|
||||
} else {
|
||||
// 获取对话列表
|
||||
await refreshConversations()
|
||||
|
||||
// 如果没有当前对话,自动创建一个
|
||||
if (!chatStore.currentConversation && chatStore.conversations.length === 0) {
|
||||
await createNewChat()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: var(--gradient-primary);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.collapsed {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.logo h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
|
||||
.new-chat-btn {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-md);
|
||||
|
||||
.list-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.conversations {
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius-small);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
|
||||
.conversation-time {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.conversation-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conversation-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover .more-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-conversations {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--text-secondary);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--gradient-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
font-size: 12px;
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
|
||||
.chat-header {
|
||||
padding: var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: white;
|
||||
|
||||
.chat-info {
|
||||
.chat-title {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
|
||||
.ant-btn {
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
|
||||
.welcome-screen {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
color: white;
|
||||
max-width: 500px;
|
||||
|
||||
.welcome-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.welcome-description {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
.message-item {
|
||||
display: flex;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
|
||||
&.user {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.assistant {
|
||||
.message-bubble {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--gradient-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
margin: 0 var(--spacing-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 70%;
|
||||
|
||||
.message-bubble {
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow);
|
||||
|
||||
&.typing {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
}
|
||||
|
||||
.message-text {
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.emotion-analysis {
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-area {
|
||||
padding: var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-md);
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
|
||||
.ant-btn {
|
||||
&.active {
|
||||
color: var(--primary-color);
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打字动画
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
|
||||
&:nth-child(1) { animation-delay: -0.32s; }
|
||||
&:nth-child(2) { animation-delay: -0.16s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
z-index: 1000;
|
||||
transform: translateX(-100%);
|
||||
|
||||
&:not(.collapsed) {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="chat-simple">
|
||||
<div class="chat-header">
|
||||
<h1>AI对话页面</h1>
|
||||
<a-button @click="goBack">返回首页</a-button>
|
||||
</div>
|
||||
|
||||
<div class="chat-content">
|
||||
<div class="welcome-message">
|
||||
<h2>欢迎来到AI对话</h2>
|
||||
<p>这是一个简化版的聊天页面,用于测试路由功能。</p>
|
||||
|
||||
<div class="test-buttons">
|
||||
<a-button type="primary" @click="testFunction">测试按钮</a-button>
|
||||
<a-button @click="$router.push('/history')">查看历史</a-button>
|
||||
<a-button @click="$router.push('/analysis')">情绪分析</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const testFunction = () => {
|
||||
alert('测试按钮工作正常!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-simple {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
|
||||
.welcome-message {
|
||||
h2 {
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
margin-bottom: 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.test-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header glass">
|
||||
<div class="header-content">
|
||||
<div class="page-title">
|
||||
<router-link to="/" class="back-btn">
|
||||
<ArrowLeftOutlined />
|
||||
</router-link>
|
||||
<h1 class="gradient-text">对话历史</h1>
|
||||
<span class="subtitle">查看和管理您的所有对话记录</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-button type="primary" class="gradient-btn" @click="goToChat">
|
||||
<MessageOutlined />
|
||||
新建对话
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="page-content">
|
||||
<div class="content-container">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">
|
||||
<MessageOutlined />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-number">{{ totalConversations }}</div>
|
||||
<div class="stat-label">总对话数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">
|
||||
<CommentOutlined />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-number">{{ totalMessages }}</div>
|
||||
<div class="stat-label">总消息数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">
|
||||
<ClockCircleOutlined />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-number">{{ activeConversations }}</div>
|
||||
<div class="stat-label">活跃对话</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">
|
||||
<CalendarOutlined />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-number">{{ todayConversations }}</div>
|
||||
<div class="stat-label">今日对话</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史记录面板 -->
|
||||
<div class="history-panel-container card">
|
||||
<HistoryPanel />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
MessageOutlined,
|
||||
CommentOutlined,
|
||||
ClockCircleOutlined,
|
||||
CalendarOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import HistoryPanel from '@/components/HistoryPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
// 计算属性
|
||||
const totalConversations = computed(() => {
|
||||
return chatStore.conversations.length
|
||||
})
|
||||
|
||||
const totalMessages = computed(() => {
|
||||
return chatStore.conversations.reduce((total, conv) => {
|
||||
return total + (conv.messageCount || 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const activeConversations = computed(() => {
|
||||
return chatStore.conversations.filter(conv => conv.status === 'active').length
|
||||
})
|
||||
|
||||
const todayConversations = computed(() => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
return chatStore.conversations.filter(conv => {
|
||||
const convDate = new Date(conv.createTime)
|
||||
return convDate >= today
|
||||
}).length
|
||||
})
|
||||
|
||||
// 方法
|
||||
const goToChat = () => {
|
||||
router.push('/chat')
|
||||
}
|
||||
|
||||
// 组件挂载
|
||||
onMounted(async () => {
|
||||
// 获取对话列表
|
||||
if (chatStore.conversations.length === 0) {
|
||||
await chatStore.fetchConversations(userStore.userInfo.id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.history-page {
|
||||
min-height: 100vh;
|
||||
background: var(--gradient-primary);
|
||||
|
||||
.page-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: var(--spacing-lg) 0;
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
|
||||
.back-btn {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 20px;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-left: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
.gradient-btn {
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: var(--spacing-xl) var(--spacing-lg);
|
||||
|
||||
.content-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
|
||||
.stat-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background: var(--gradient-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.history-panel-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: var(--spacing-xl);
|
||||
min-height: 600px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.history-page {
|
||||
.page-header {
|
||||
.header-content {
|
||||
padding: 0 var(--spacing-md);
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
.subtitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: var(--spacing-lg) var(--spacing-md);
|
||||
|
||||
.stats-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
|
||||
.stat-card {
|
||||
padding: var(--spacing-md);
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
.stat-number {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.history-panel-container {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="history-simple">
|
||||
<div class="page-header">
|
||||
<h1>对话历史</h1>
|
||||
<a-button @click="goBack">返回首页</a-button>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<div class="welcome-message">
|
||||
<h2>对话历史记录</h2>
|
||||
<p>这里将显示您的所有对话历史记录。</p>
|
||||
|
||||
<div class="test-buttons">
|
||||
<a-button type="primary" @click="testFunction">测试按钮</a-button>
|
||||
<a-button @click="$router.push('/chat')">开始对话</a-button>
|
||||
<a-button @click="$router.push('/analysis')">情绪分析</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const testFunction = () => {
|
||||
alert('历史记录页面测试按钮工作正常!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.history-simple {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
|
||||
.welcome-message {
|
||||
h2 {
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
margin-bottom: 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.test-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,569 @@
|
||||
<template>
|
||||
<div class="home-container">
|
||||
<!-- 导航栏 -->
|
||||
<header class="header glass">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<h1 class="gradient-text">情绪博物馆</h1>
|
||||
<span class="subtitle">AI心理健康助手</span>
|
||||
</div>
|
||||
<nav class="nav-menu">
|
||||
<a-button type="text" class="nav-item" @click="$router.push('/chat')">
|
||||
<MessageOutlined />
|
||||
AI对话
|
||||
</a-button>
|
||||
<a-button type="text" class="nav-item" @click="$router.push('/history')">
|
||||
<HistoryOutlined />
|
||||
历史记录
|
||||
</a-button>
|
||||
<a-button type="text" class="nav-item" @click="$router.push('/analysis')">
|
||||
<BarChartOutlined />
|
||||
情绪分析
|
||||
</a-button>
|
||||
|
||||
<!-- 用户状态区域 -->
|
||||
<div class="user-area">
|
||||
<template v-if="userStore.isLoggedIn">
|
||||
<a-dropdown>
|
||||
<a-button type="text" class="nav-item user-btn">
|
||||
<UserOutlined />
|
||||
{{ userStore.userInfo.username || userStore.userInfo.account }}
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="profile">
|
||||
<UserOutlined />
|
||||
个人资料
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout" @click="handleLogout">
|
||||
<LogoutOutlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button type="text" class="nav-item" @click="$router.push('/login')">
|
||||
<LoginOutlined />
|
||||
登录
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="main-content">
|
||||
<div class="hero-section">
|
||||
<div class="hero-content fade-in-up">
|
||||
<h2 class="hero-title">
|
||||
欢迎来到情绪博物馆
|
||||
</h2>
|
||||
<p class="hero-description">
|
||||
您的专属AI心理健康助手,提供24/7情绪支持、心理分析和个性化建议
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="start-chat-btn"
|
||||
@click="startChat"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; margin-right: 16px;"
|
||||
>
|
||||
<MessageOutlined />
|
||||
开始对话
|
||||
</a-button>
|
||||
<a-button
|
||||
size="large"
|
||||
class="learn-more-btn"
|
||||
@click="scrollToFeatures"
|
||||
style="background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); color: white;"
|
||||
>
|
||||
了解更多
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 装饰性元素 -->
|
||||
<div class="hero-decoration">
|
||||
<div class="floating-card card bounce-in" style="animation-delay: 0.2s">
|
||||
<HeartOutlined class="icon" />
|
||||
<span>情绪识别</span>
|
||||
</div>
|
||||
<div class="floating-card card bounce-in" style="animation-delay: 0.4s">
|
||||
<BulbOutlined class="icon" />
|
||||
<span>智能建议</span>
|
||||
</div>
|
||||
<div class="floating-card card bounce-in" style="animation-delay: 0.6s">
|
||||
<SafetyOutlined class="icon" />
|
||||
<span>隐私保护</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 功能特性 -->
|
||||
<section class="features-section" ref="featuresRef">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title gradient-text">核心功能</h3>
|
||||
<p class="section-description">专业的AI技术,贴心的情绪关怀</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card card" v-for="feature in features" :key="feature.id">
|
||||
<div class="feature-icon">
|
||||
<component :is="feature.icon" />
|
||||
</div>
|
||||
<h4 class="feature-title">{{ feature.title }}</h4>
|
||||
<p class="feature-description">{{ feature.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 统计数据 -->
|
||||
<section class="stats-section">
|
||||
<div class="stats-container glass">
|
||||
<div class="stat-item" v-for="stat in stats" :key="stat.label">
|
||||
<div class="stat-number gradient-text">{{ stat.value }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- API测试组件 (仅开发环境) -->
|
||||
<section v-if="showApiTest" class="api-test-section">
|
||||
<ApiTest />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
<p>© 2025 情绪博物馆. 用心守护每一份情绪</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
MessageOutlined,
|
||||
HistoryOutlined,
|
||||
BarChartOutlined,
|
||||
HeartOutlined,
|
||||
BulbOutlined,
|
||||
SafetyOutlined,
|
||||
RobotOutlined,
|
||||
LineChartOutlined,
|
||||
ClockCircleOutlined,
|
||||
LockOutlined,
|
||||
UserOutlined,
|
||||
LoginOutlined,
|
||||
LogoutOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ENV_CONFIG } from '@/config/env'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import ApiTest from '@/components/ApiTest.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const featuresRef = ref(null)
|
||||
|
||||
// 是否显示API测试组件 (仅开发环境)
|
||||
const showApiTest = computed(() => ENV_CONFIG.isDevelopment)
|
||||
|
||||
// 功能特性数据
|
||||
const features = ref([
|
||||
{
|
||||
id: 1,
|
||||
icon: RobotOutlined,
|
||||
title: 'AI智能对话',
|
||||
description: '基于先进的自然语言处理技术,提供自然流畅的对话体验'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: LineChartOutlined,
|
||||
title: '情绪分析',
|
||||
description: '实时分析您的情绪状态,提供专业的心理健康评估'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: ClockCircleOutlined,
|
||||
title: '24/7支持',
|
||||
description: '全天候在线服务,随时随地为您提供情绪支持和心理疏导'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: LockOutlined,
|
||||
title: '隐私保护',
|
||||
description: '严格保护用户隐私,所有对话内容都经过加密处理'
|
||||
}
|
||||
])
|
||||
|
||||
// 统计数据
|
||||
const stats = ref([
|
||||
{ value: '10,000+', label: '用户信赖' },
|
||||
{ value: '50,000+', label: '对话次数' },
|
||||
{ value: '95%', label: '满意度' },
|
||||
{ value: '24/7', label: '在线服务' }
|
||||
])
|
||||
|
||||
// 开始对话
|
||||
const startChat = () => {
|
||||
console.log('开始对话按钮被点击')
|
||||
router.push('/chat')
|
||||
}
|
||||
|
||||
// 滚动到功能区域
|
||||
const scrollToFeatures = () => {
|
||||
featuresRef.value?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// 处理登出
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userStore.logout()
|
||||
message.success('已退出登录')
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error)
|
||||
message.error('登出失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 页面加载动画
|
||||
document.body.style.overflow = 'hidden'
|
||||
setTimeout(() => {
|
||||
document.body.style.overflow = 'auto'
|
||||
}, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-container {
|
||||
min-height: 100vh;
|
||||
background: var(--gradient-primary);
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
padding: var(--spacing-md) 0;
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.logo {
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-left: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
|
||||
.nav-item {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--border-radius-small);
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.user-area {
|
||||
margin-left: var(--spacing-md);
|
||||
|
||||
.user-btn {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding: var(--spacing-xxl) var(--spacing-lg);
|
||||
|
||||
.hero-content {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
color: white;
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 18px;
|
||||
margin-bottom: var(--spacing-xxl);
|
||||
opacity: 0.9;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.start-chat-btn {
|
||||
height: 50px;
|
||||
padding: 0 var(--spacing-xl);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.learn-more-btn {
|
||||
height: 50px;
|
||||
padding: 0 var(--spacing-xl);
|
||||
font-size: 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-decoration {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
.floating-card {
|
||||
position: absolute;
|
||||
padding: var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
white-space: nowrap;
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:nth-child(1) {
|
||||
top: -60px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
top: 20px;
|
||||
right: -40px;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
top: 100px;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.features-section {
|
||||
padding: var(--spacing-xxl) var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xxl);
|
||||
color: white;
|
||||
|
||||
.section-title {
|
||||
font-size: 36px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
|
||||
.feature-card {
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
|
||||
.feature-icon {
|
||||
font-size: 48px;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
padding: var(--spacing-xxl) var(--spacing-lg);
|
||||
|
||||
.stats-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-xl);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
text-align: center;
|
||||
|
||||
.stat-item {
|
||||
.stat-number {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-test-section {
|
||||
padding: var(--spacing-xxl) var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
|
||||
:deep(.ant-card) {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-large);
|
||||
box-shadow: var(--shadow-large);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: var(--spacing-xl) var(--spacing-lg);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
|
||||
.footer-content {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
.header-content {
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
gap: var(--spacing-md);
|
||||
|
||||
.nav-item {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
.hero-content {
|
||||
.hero-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-decoration {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.features-section {
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
.stats-container {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="home-test">
|
||||
<h1>情绪博物馆测试页面</h1>
|
||||
<p>如果您能看到这个页面,说明Vue应用正在正常工作!</p>
|
||||
|
||||
<div class="test-buttons">
|
||||
<button @click="testAlert" class="test-btn">测试按钮1</button>
|
||||
<button @click="goToChat" class="test-btn">前往聊天页面</button>
|
||||
<button @click="goToHistory" class="test-btn">前往历史页面</button>
|
||||
<button @click="goToAnalysis" class="test-btn">前往分析页面</button>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<p>当前时间: {{ currentTime }}</p>
|
||||
<p>页面加载状态: 正常</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const currentTime = ref('')
|
||||
|
||||
const updateTime = () => {
|
||||
currentTime.value = new Date().toLocaleString()
|
||||
}
|
||||
|
||||
const testAlert = () => {
|
||||
alert('测试按钮工作正常!Vue应用运行正常!')
|
||||
}
|
||||
|
||||
const goToChat = () => {
|
||||
router.push('/chat')
|
||||
}
|
||||
|
||||
const goToHistory = () => {
|
||||
router.push('/history')
|
||||
}
|
||||
|
||||
const goToAnalysis = () => {
|
||||
router.push('/analysis')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
setInterval(updateTime, 1000)
|
||||
console.log('HomeTest页面加载成功')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-test {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.test-buttons {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.test-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.info {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.info p {
|
||||
margin: 10px 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,537 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card glass">
|
||||
<div class="login-header">
|
||||
<h1 class="gradient-text">情绪博物馆</h1>
|
||||
<p class="subtitle">AI心理健康助手</p>
|
||||
</div>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" centered>
|
||||
<!-- 登录标签页 -->
|
||||
<a-tab-pane key="login" tab="登录">
|
||||
<a-form
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
@finish="handleLogin"
|
||||
layout="vertical"
|
||||
class="login-form"
|
||||
>
|
||||
<a-form-item name="account" label="账号">
|
||||
<a-input
|
||||
v-model:value="loginForm.account"
|
||||
placeholder="请输入账号/邮箱/手机号"
|
||||
size="large"
|
||||
:prefix="h(UserOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password" label="密码">
|
||||
<a-input-password
|
||||
v-model:value="loginForm.password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix="h(LockOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="captcha" label="验证码">
|
||||
<div class="captcha-wrapper">
|
||||
<CaptchaInput
|
||||
v-model="loginForm.captcha"
|
||||
v-model:captcha-id="loginForm.captchaId"
|
||||
placeholder="请输入验证码"
|
||||
size="large"
|
||||
@enter="handleLogin"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div class="form-options">
|
||||
<a-checkbox v-model:checked="loginForm.rememberMe">
|
||||
记住我
|
||||
</a-checkbox>
|
||||
<a-button type="link" size="small" @click="showSliderCaptcha = true">
|
||||
使用滑块验证
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
block
|
||||
:loading="loginLoading"
|
||||
class="login-btn"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 注册标签页 -->
|
||||
<a-tab-pane key="register" tab="注册">
|
||||
<a-form
|
||||
:model="registerForm"
|
||||
:rules="registerRules"
|
||||
@finish="handleRegister"
|
||||
layout="vertical"
|
||||
class="register-form"
|
||||
>
|
||||
<a-form-item name="account" label="账号">
|
||||
<a-input
|
||||
v-model:value="registerForm.account"
|
||||
placeholder="请输入账号"
|
||||
size="large"
|
||||
:prefix="h(UserOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="email" label="邮箱">
|
||||
<a-input
|
||||
v-model:value="registerForm.email"
|
||||
placeholder="请输入邮箱"
|
||||
size="large"
|
||||
:prefix="h(MailOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="phone" label="手机号">
|
||||
<a-input
|
||||
v-model:value="registerForm.phone"
|
||||
placeholder="请输入手机号(可选)"
|
||||
size="large"
|
||||
:prefix="h(PhoneOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password" label="密码">
|
||||
<a-input-password
|
||||
v-model:value="registerForm.password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix="h(LockOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="confirmPassword" label="确认密码">
|
||||
<a-input-password
|
||||
v-model:value="registerForm.confirmPassword"
|
||||
placeholder="请再次输入密码"
|
||||
size="large"
|
||||
:prefix="h(LockOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="username" label="用户名">
|
||||
<a-input
|
||||
v-model:value="registerForm.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
:prefix="h(UserOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="captcha" label="验证码">
|
||||
<div class="captcha-wrapper">
|
||||
<CaptchaInput
|
||||
v-model="registerForm.captcha"
|
||||
v-model:captcha-id="registerForm.captchaId"
|
||||
placeholder="请输入验证码"
|
||||
size="large"
|
||||
@enter="handleRegister"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
block
|
||||
:loading="registerLoading"
|
||||
class="register-btn"
|
||||
>
|
||||
注册
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<!-- 第三方登录 -->
|
||||
<SocialLogin @success="handleSocialLoginSuccess" @error="handleSocialLoginError" />
|
||||
|
||||
<div class="login-footer">
|
||||
<a-button type="link" @click="goHome">
|
||||
<ArrowLeftOutlined />
|
||||
返回首页
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 滑块验证码模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showSliderCaptcha"
|
||||
title="滑块验证"
|
||||
:footer="null"
|
||||
:width="350"
|
||||
centered
|
||||
>
|
||||
<SliderCaptcha
|
||||
ref="sliderCaptchaRef"
|
||||
@success="handleSliderSuccess"
|
||||
@error="handleSliderError"
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
MailOutlined,
|
||||
PhoneOutlined,
|
||||
ArrowLeftOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { userApi } from '@/api/user'
|
||||
import CaptchaInput from '@/components/CaptchaInput.vue'
|
||||
import SliderCaptcha from '@/components/SliderCaptcha.vue'
|
||||
import SocialLogin from '@/components/SocialLogin.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 当前活跃标签页
|
||||
const activeTab = ref('login')
|
||||
|
||||
// 登录表单
|
||||
const loginForm = reactive({
|
||||
account: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
captchaId: '',
|
||||
captcha: ''
|
||||
})
|
||||
|
||||
// 注册表单
|
||||
const registerForm = reactive({
|
||||
account: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
username: '',
|
||||
captchaId: '',
|
||||
captcha: ''
|
||||
})
|
||||
|
||||
// 加载状态
|
||||
const loginLoading = ref(false)
|
||||
const registerLoading = ref(false)
|
||||
const showSliderCaptcha = ref(false)
|
||||
|
||||
// 组件引用
|
||||
const sliderCaptchaRef = ref(null)
|
||||
|
||||
// 登录表单验证规则
|
||||
const loginRules = {
|
||||
account: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
|
||||
],
|
||||
captchaId: [
|
||||
{ required: true, message: '请获取验证码', trigger: 'blur' }
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 注册表单验证规则
|
||||
const registerRules = {
|
||||
account: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '账号长度为3-20位', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '账号只能包含字母、数字和下划线', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度为6-20位', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (value !== registerForm.password) {
|
||||
return Promise.reject('两次密码输入不一致')
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 10, message: '用户名长度为2-10位', trigger: 'blur' }
|
||||
],
|
||||
captchaId: [
|
||||
{ required: true, message: '请获取验证码', trigger: 'blur' }
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async (values) => {
|
||||
try {
|
||||
loginLoading.value = true
|
||||
const response = await userApi.login(values)
|
||||
|
||||
if (response.success) {
|
||||
const { accessToken, refreshToken, userInfo } = response.data
|
||||
|
||||
// 存储token
|
||||
localStorage.setItem('token', accessToken)
|
||||
localStorage.setItem('refreshToken', refreshToken)
|
||||
|
||||
// 更新用户状态
|
||||
userStore.setUser(userInfo)
|
||||
|
||||
message.success('登录成功')
|
||||
|
||||
// 跳转到首页或之前的页面
|
||||
const redirect = router.currentRoute.value.query.redirect || '/'
|
||||
router.push(redirect)
|
||||
} else {
|
||||
message.error(response.message || '登录失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
message.error('登录失败,请稍后重试')
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理注册
|
||||
const handleRegister = async (values) => {
|
||||
try {
|
||||
registerLoading.value = true
|
||||
const response = await userApi.register(values)
|
||||
|
||||
if (response.success) {
|
||||
message.success('注册成功,请登录')
|
||||
activeTab.value = 'login'
|
||||
|
||||
// 清空注册表单
|
||||
Object.keys(registerForm).forEach(key => {
|
||||
registerForm[key] = ''
|
||||
})
|
||||
} else {
|
||||
message.error(response.message || '注册失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error)
|
||||
message.error('注册失败,请稍后重试')
|
||||
} finally {
|
||||
registerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回首页
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// 处理第三方登录成功
|
||||
const handleSocialLoginSuccess = (userInfo) => {
|
||||
message.success('登录成功')
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// 处理第三方登录失败
|
||||
const handleSocialLoginError = (error) => {
|
||||
console.error('第三方登录失败:', error)
|
||||
}
|
||||
|
||||
// 处理滑块验证成功
|
||||
const handleSliderSuccess = (captchaId) => {
|
||||
showSliderCaptcha.value = false
|
||||
// 可以在这里设置滑块验证码ID到表单中
|
||||
if (activeTab.value === 'login') {
|
||||
loginForm.captchaId = captchaId
|
||||
loginForm.captcha = 'slider_verified'
|
||||
} else {
|
||||
registerForm.captchaId = captchaId
|
||||
registerForm.captcha = 'slider_verified'
|
||||
}
|
||||
message.success('滑块验证成功')
|
||||
}
|
||||
|
||||
// 处理滑块验证失败
|
||||
const handleSliderError = () => {
|
||||
message.error('滑块验证失败,请重试')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px;
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(20px);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(45deg, #fff, #f0f0f0);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.login-form,
|
||||
.register-form {
|
||||
:deep(.ant-form-item-label > label) {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-input-password .ant-input) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-input-prefix) {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
:deep(.ant-checkbox-wrapper) {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn,
|
||||
.register-btn {
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
border: none;
|
||||
height: 45px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(45deg, #5a6fd8, #6a4190);
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.ant-checkbox-wrapper {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.ant-btn-link {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
|
||||
:deep(.ant-btn-link) {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tab) {
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
|
||||
&.ant-tabs-tab-active {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-ink-bar) {
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,195 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>消息拆分测试</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.test-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.test-section h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
button {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
}
|
||||
button:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
.result {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.message-item {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
.message-meta {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.message-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>消息拆分功能测试</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>1. 测试聊天消息拆分</h3>
|
||||
<button onclick="testChatSplit()">发送测试消息</button>
|
||||
<div id="chatResult" class="result"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>2. 获取会话消息列表</h3>
|
||||
<input type="text" id="conversationId" placeholder="输入会话ID" style="width: 300px; padding: 8px; margin-right: 10px;">
|
||||
<button onclick="getMessages()">获取消息</button>
|
||||
<div id="messagesResult" class="result"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>3. 测试拆分接口</h3>
|
||||
<button onclick="testSplitApi()">测试双换行拆分</button>
|
||||
<button onclick="testSingleLineSplit()">测试单换行拆分</button>
|
||||
<button onclick="testNoSplit()">测试无换行</button>
|
||||
<div id="splitResult" class="result"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = 'http://47.111.10.27/api';
|
||||
|
||||
async function testChatSplit() {
|
||||
const resultDiv = document.getElementById('chatResult');
|
||||
resultDiv.innerHTML = '发送中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/ai/guest/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: '请详细介绍一下你的功能,包括聊天、情感分析、生活助手等各个方面的能力,并分段说明。'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
resultDiv.innerHTML = JSON.stringify(data, null, 2);
|
||||
|
||||
// 如果有会话ID,自动填入
|
||||
if (data.data && data.data.conversationId) {
|
||||
document.getElementById('conversationId').value = data.data.conversationId;
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = '错误: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function getMessages() {
|
||||
const conversationId = document.getElementById('conversationId').value;
|
||||
const resultDiv = document.getElementById('messagesResult');
|
||||
|
||||
if (!conversationId) {
|
||||
resultDiv.innerHTML = '请输入会话ID';
|
||||
return;
|
||||
}
|
||||
|
||||
resultDiv.innerHTML = '获取中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/ai/guest/conversation/${conversationId}/messages`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code === 200 && data.data) {
|
||||
let html = `<h4>找到 ${data.data.length} 条消息:</h4>`;
|
||||
data.data.forEach((msg, index) => {
|
||||
html += `
|
||||
<div class="message-item">
|
||||
<div class="message-meta">
|
||||
#${index + 1} | ID: ${msg.messageId} | 发送者: ${msg.sender} | 时间: ${msg.timestamp}
|
||||
</div>
|
||||
<div class="message-content">${msg.content}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
resultDiv.innerHTML = html;
|
||||
} else {
|
||||
resultDiv.innerHTML = JSON.stringify(data, null, 2);
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = '错误: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function testSplitApi() {
|
||||
await testSplit('测试双换行拆分功能');
|
||||
}
|
||||
|
||||
async function testSingleLineSplit() {
|
||||
await testSplit('测试单换行拆分功能');
|
||||
}
|
||||
|
||||
async function testNoSplit() {
|
||||
await testSplit('测试无换行符功能');
|
||||
}
|
||||
|
||||
async function testSplit(message) {
|
||||
const resultDiv = document.getElementById('splitResult');
|
||||
resultDiv.innerHTML = '测试中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/ai/guest/test/split`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ message })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
resultDiv.innerHTML = JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = '错误: ' + error.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 加载环境变量
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
base: mode === 'production' ? '/emotion-museum/' : '/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
proxy: {
|
||||
// 所有API请求统一通过网关代理
|
||||
'/api': {
|
||||
target: env.VITE_API_TARGET || 'http://localhost:19000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
},
|
||||
// 验证码服务通过网关代理
|
||||
'/captcha': {
|
||||
target: env.VITE_API_TARGET || 'http://localhost:19000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
},
|
||||
// OAuth服务通过网关代理
|
||||
'/oauth': {
|
||||
target: env.VITE_API_TARGET || 'http://localhost:19000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
sourcemap: mode === 'development',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
chunkFileNames: 'assets/js/[name]-[hash].js',
|
||||
entryFileNames: 'assets/js/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
|
||||
}
|
||||
}
|
||||
},
|
||||
define: {
|
||||
// 将环境变量注入到应用中
|
||||
__APP_ENV__: JSON.stringify(env.VITE_APP_ENV),
|
||||
__API_BASE_URL__: JSON.stringify(env.VITE_API_BASE_URL),
|
||||
__DEBUG_MODE__: JSON.stringify(env.VITE_DEBUG_MODE === 'true')
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user