feat: 更新了UI组件和Tailwind配置,并同步了依赖项和新增了环境变量文件。新增代理服务器和通义百炼平台的。
This commit is contained in:
parent
83fbfc2c37
commit
c97e227685
|
|
@ -1,24 +1,26 @@
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
.env
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
# Editor directories and files
|
||||||
.idea
|
.vscode/*
|
||||||
.DS_Store
|
!.vscode/extensions.json
|
||||||
*.suo
|
.idea
|
||||||
*.ntvs*
|
.DS_Store
|
||||||
*.njsproj
|
*.suo
|
||||||
*.sln
|
*.ntvs*
|
||||||
*.sw?
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
|
||||||
42
LICENSE
42
LICENSE
|
|
@ -1,21 +1,21 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 zll-it
|
Copyright (c) 2026 zll-it
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
furnished to do so, subject to the following conditions:
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
The above copyright notice and this permission notice shall be included in all
|
||||||
copies or substantial portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
|
||||||
208
README.md
208
README.md
|
|
@ -1,104 +1,104 @@
|
||||||
# AI-CHAT-UI
|
# AI-CHAT-UI
|
||||||
|
|
||||||
一个基于 Vue 和 markstream-vue 构建的现代化 AI 对话界面,提供丰富的交互功能和精美的视觉体验。
|
一个基于 Vue 和 markstream-vue 构建的现代化 AI 对话界面,提供丰富的交互功能和精美的视觉体验。
|
||||||
|
|
||||||
## 页面展示
|
## 页面展示
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## ✨ 核心功能
|
## ✨ 核心功能
|
||||||
|
|
||||||
| 功能 | 详细描述 |
|
| 功能 | 详细描述 |
|
||||||
|-------|-------------------------------------|
|
|-------|-------------------------------------|
|
||||||
| 对话历史 | 支持多对话管理、置顶、重命名、删除 |
|
| 对话历史 | 支持多对话管理、置顶、重命名、删除 |
|
||||||
| 新建对话 | 快捷键 Ctrl+N 或点击按钮快速创建 |
|
| 新建对话 | 快捷键 Ctrl+N 或点击按钮快速创建 |
|
||||||
| 页面布局 | 支持宽屏/标准模式切换,适配不同使用场景 |
|
| 页面布局 | 支持宽屏/标准模式切换,适配不同使用场景 |
|
||||||
| 附件上传 | 支持图片、文件上传和在线预览 |
|
| 附件上传 | 支持图片、文件上传和在线预览 |
|
||||||
| 智能搜索 | 深度搜索/联网搜索,工具栏开关一键切换 |
|
| 智能搜索 | 深度搜索/联网搜索,工具栏开关一键切换 |
|
||||||
| 消息操作 | 消息栏支持点赞、点踩、复制操作 |
|
| 消息操作 | 消息栏支持点赞、点踩、复制操作 |
|
||||||
| 精美UI | 现代化设计,支持暗色主题,视觉体验优秀 |
|
| 精美UI | 现代化设计,支持暗色主题,视觉体验优秀 |
|
||||||
| 消息布局 | AI/用户消息左右分布(用户右侧,AI 左侧),符合使用习惯 |
|
| 消息布局 | AI/用户消息左右分布(用户右侧,AI 左侧),符合使用习惯 |
|
||||||
| 多类型消息 | 支持文本、图片、视频、附件、推荐选项等多种消息类型展示 |
|
| 多类型消息 | 支持文本、图片、视频、附件、推荐选项等多种消息类型展示 |
|
||||||
| 快捷键系统 | 完整的快捷键支持,提升操作效率 |
|
| 快捷键系统 | 完整的快捷键支持,提升操作效率 |
|
||||||
| 流式输出 | 基于 markstream-vue 实现流畅的AI回答流式展示 |
|
| 流式输出 | 基于 markstream-vue 实现流畅的AI回答流式展示 |
|
||||||
| 渲染展示 | 支持mermaid、代码块、ECharts、Thinking等流式展示 |
|
| 渲染展示 | 支持mermaid、代码块、ECharts、Thinking等流式展示 |
|
||||||
| 对话搜索 | 模态框快速搜索历史对话内容 |
|
| 对话搜索 | 模态框快速搜索历史对话内容 |
|
||||||
| 全局设置 | 外观、对话默认值、功能开关、隐私设置等全局配置 |
|
| 全局设置 | 外观、对话默认值、功能开关、隐私设置等全局配置 |
|
||||||
| 对话设置 | 单个对话的模型、温度、提示词等个性化设置 |
|
| 对话设置 | 单个对话的模型、温度、提示词等个性化设置 |
|
||||||
| 主题切换 | 支持浅色/深色/跟随系统三种主题模式 |
|
| 主题切换 | 支持浅色/深色/跟随系统三种主题模式 |
|
||||||
| 字体大小 | 小/中/大三档字体大小可选,适配不同阅读习惯 |
|
| 字体大小 | 小/中/大三档字体大小可选,适配不同阅读习惯 |
|
||||||
| 数据管理 | 导入/导出设置、清除数据,保障数据安全 |
|
| 数据管理 | 导入/导出设置、清除数据,保障数据安全 |
|
||||||
| 预设提示词 | 快速选择常用角色设定,提升对话效率 |
|
| 预设提示词 | 快速选择常用角色设定,提升对话效率 |
|
||||||
|
|
||||||
## 🛠 技术栈
|
## 🛠 技术栈
|
||||||
|
|
||||||
- **核心框架**: Vue
|
- **核心框架**: Vue
|
||||||
|
|
||||||
- **流式渲染**: markstream-vue
|
- **流式渲染**: markstream-vue
|
||||||
|
|
||||||
- **UI 设计**: 现代化响应式设计,支持暗色主题
|
- **UI 设计**: 现代化响应式设计,支持暗色主题
|
||||||
|
|
||||||
- **交互体验**: 丰富的快捷键系统和消息操作
|
- **交互体验**: 丰富的快捷键系统和消息操作
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
```Bash
|
```Bash
|
||||||
|
|
||||||
# 克隆仓库
|
# 克隆仓库
|
||||||
git clone https://github.com/zll-it/ai-chat-ui.git
|
git clone https://github.com/zll-it/ai-chat-ui.git
|
||||||
|
|
||||||
# 进入项目目录
|
# 进入项目目录
|
||||||
cd ai-chat-ui
|
cd ai-chat-ui
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 启动开发服务器
|
# 启动开发服务器
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# 构建生产版本
|
# 构建生产版本
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📋 使用说明
|
## 📋 使用说明
|
||||||
|
|
||||||
### 基础操作
|
### 基础操作
|
||||||
|
|
||||||
- **新建对话**: `Ctrl+N` 快捷键或点击页面右上角 "+" 按钮
|
- **新建对话**: `Ctrl+N` 快捷键或点击页面右上角 "+" 按钮
|
||||||
|
|
||||||
- **切换布局**: 点击页面右下角布局切换按钮
|
- **切换布局**: 点击页面右下角布局切换按钮
|
||||||
|
|
||||||
- **主题切换**: 设置面板中选择浅色/深色/跟随系统
|
- **主题切换**: 设置面板中选择浅色/深色/跟随系统
|
||||||
|
|
||||||
- **搜索对话**: 使用页面顶部搜索框或快捷键 `Ctrl+K`
|
- **搜索对话**: 使用页面顶部搜索框或快捷键 `Ctrl+K`
|
||||||
|
|
||||||
### 快捷键一览
|
### 快捷键一览
|
||||||
|
|
||||||
| 操作 | 快捷键 |
|
| 操作 | 快捷键 |
|
||||||
|--------|---------------------|
|
|--------|---------------------|
|
||||||
| 新建对话 | Ctrl+N |
|
| 新建对话 | Ctrl+N |
|
||||||
| 搜索对话 | Ctrl+K |
|
| 搜索对话 | Ctrl+K |
|
||||||
| 复制当前消息 | Ctrl+C (消息 hover 时) |
|
| 复制当前消息 | Ctrl+C (消息 hover 时) |
|
||||||
| 切换布局 | Ctrl+Shift+L |
|
| 切换布局 | Ctrl+Shift+L |
|
||||||
|
|
||||||
## 📄 许可证
|
## 📄 许可证
|
||||||
|
|
||||||
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
|
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
|
||||||
|
|
||||||
## 💡 贡献
|
## 💡 贡献
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request 来帮助改进这个项目!
|
欢迎提交 Issue 和 Pull Request 来帮助改进这个项目!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<sub>Made with ❤️ using Vue & markstream-vue</sub>
|
<sub>Made with ❤️ using Vue & markstream-vue</sub>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
30
index.html
30
index.html
|
|
@ -1,16 +1,16 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vue.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vue.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AI-CHAT-UI - 企业级智能对话</title>
|
<title>AI-CHAT-UI - 企业级智能对话</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
84
package.json
84
package.json
|
|
@ -1,42 +1,42 @@
|
||||||
{
|
{
|
||||||
"name": "ai-chat-ui",
|
"name": "ai-chat-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/fetch-event-source": "^2.0.1",
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
"@terrastruct/d2": "^0.1.33",
|
"@terrastruct/d2": "^0.1.33",
|
||||||
"@unocss/reset": "^66.6.0",
|
"@unocss/reset": "^66.6.0",
|
||||||
"@vueuse/core": "^14.2.0",
|
"@vueuse/core": "^14.2.0",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"lucide-vue-next": "^0.563.0",
|
"lucide-vue-next": "^0.563.0",
|
||||||
"markstream-vue": "^0.0.7-beta.4",
|
"markstream-vue": "^0.0.7-beta.4",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"shiki": "^3.22.0",
|
"shiki": "^3.22.0",
|
||||||
"stream-markdown": "^0.0.14",
|
"stream-markdown": "^0.0.14",
|
||||||
"stream-monaco": "^0.0.18",
|
"stream-monaco": "^0.0.18",
|
||||||
"unocss": "^66.6.0",
|
"unocss": "^66.6.0",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^5.0.2"
|
"vue-router": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"sass": "^1.97.3",
|
"sass": "^1.97.3",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
"vue-tsc": "^3.1.4"
|
"vue-tsc": "^3.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7342
pnpm-lock.yaml
7342
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,6 @@
|
||||||
|
# 阿里云百炼 API Key
|
||||||
|
# 请在百炼控制台申请并填入此处
|
||||||
|
ALIYUN_API_KEY=your_api_key_here
|
||||||
|
|
||||||
|
# 本地中转服务器运行端口
|
||||||
|
PORT=3000
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"http-proxy-middleware": "^3.0.5",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"multer": "^2.1.0",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||||
|
const multer = require('multer');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// 配置全局请求日志,可以在终端里看到每个到达 Node 端点的请求记录
|
||||||
|
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
|
||||||
|
|
||||||
|
// 配置 CORS,允许前端项目的跨域请求
|
||||||
|
app.use(cors());
|
||||||
|
|
||||||
|
// --- 1. 流式对话请求代理配置 ---
|
||||||
|
// 在请求交给代理组件之前,拦截所有的 /api/chat-ui/chat 并强行给 Header 加上 Bearer Token
|
||||||
|
app.use('/api/chat-ui/chat', (req, res, next) => {
|
||||||
|
const apiKey = process.env.ALIYUN_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
console.error("【错误】发送代理请求前未配置 ALIYUN_API_KEY !");
|
||||||
|
} else {
|
||||||
|
req.headers['authorization'] = `Bearer ${apiKey}`;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注意:代理中间件需要在 body-parser (express.json) 之前,不然代理会导致请求体丢失
|
||||||
|
app.use(
|
||||||
|
'/api/chat-ui/chat',
|
||||||
|
createProxyMiddleware({
|
||||||
|
// 阿里云百炼由于兼容 OpenAI 格式,所以代理到此接口
|
||||||
|
target: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
|
||||||
|
changeOrigin: true,
|
||||||
|
// 去除路径前缀,确保发往阿里云的路径是纯正的 completions 路径
|
||||||
|
pathRewrite: {
|
||||||
|
'^/api/chat-ui/chat': '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- 下面的路由专门走 Node.js 业务逻辑,因此需要解析 JSON Body ---
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// --- 2. 获取模型列表 ---
|
||||||
|
app.get('/api/chat-ui/models', (req, res) => {
|
||||||
|
res.json([
|
||||||
|
{
|
||||||
|
id: "qwen-max",
|
||||||
|
name: "通义千问 Max",
|
||||||
|
description: "最强大的模型",
|
||||||
|
maxTokens: 8192,
|
||||||
|
provider: "Aliyun"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "qwen-plus",
|
||||||
|
name: "通义千问 Plus",
|
||||||
|
description: "能力均衡",
|
||||||
|
maxTokens: 8192,
|
||||||
|
provider: "Aliyun"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 内存中暂时存放对话数据用于 Mock
|
||||||
|
const conversationsDB = {};
|
||||||
|
|
||||||
|
// --- 3. 获取所有对话历史 ---
|
||||||
|
app.get('/api/chat-ui/conversations', (req, res) => {
|
||||||
|
res.json(Object.values(conversationsDB));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 4. 获取单个对话 ---
|
||||||
|
app.get('/api/chat-ui/conversations/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const conversation = conversationsDB[id];
|
||||||
|
if (conversation) {
|
||||||
|
res.json(conversation);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: '对话不存在' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 5. 保存或更新对话 ---
|
||||||
|
// 前端可能会在 /api/chat-ui/conversations/:id 用 POST 或 PUT 更新? 也可以直接提供一个保存接口
|
||||||
|
app.post('/api/chat-ui/conversations', (req, res) => {
|
||||||
|
const data = req.body;
|
||||||
|
if(!data.id) data.id = uuidv4();
|
||||||
|
conversationsDB[data.id] = data;
|
||||||
|
res.json(data);
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 6. 删除对话 ---
|
||||||
|
app.delete('/api/chat-ui/conversations/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
if (conversationsDB[id]) {
|
||||||
|
delete conversationsDB[id];
|
||||||
|
res.json({ success: true, message: "删除成功" });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: '对话不存在' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为了存储上传文件而建立临时目录
|
||||||
|
const uploadDir = path.join(__dirname, 'uploads');
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置文件上传
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: function (req, file, cb) {
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: function (req, file, cb) {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
cb(null, uniqueSuffix + '-' + file.originalname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const upload = multer({ storage: storage });
|
||||||
|
|
||||||
|
// 提供静态文件访问支持
|
||||||
|
app.use('/uploads', express.static(uploadDir));
|
||||||
|
|
||||||
|
// --- 7. 上传文件 ---
|
||||||
|
app.post('/api/chat-ui/upload', upload.single('file'), (req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: '没有文件上传' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回供前端使用和访问的 URL
|
||||||
|
res.json({
|
||||||
|
url: `http://localhost:${PORT}/uploads/${req.file.filename}`,
|
||||||
|
name: req.file.originalname,
|
||||||
|
size: req.file.size,
|
||||||
|
mimeType: req.file.mimetype
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 8. 停止生成 ---
|
||||||
|
// 这个接口对于本地代理没有实际效果,因为流的断开是通过底层 AbortController 控制的,此处直接返回成功
|
||||||
|
app.post(['/api/chat-ui/stop', '/api/chat-ui/stop/:id'], (req, res) => {
|
||||||
|
res.json({ success: true, message: "已发出停止指令" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 其他所有路由返回404
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ error: 'Endpoint not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log('====================================');
|
||||||
|
console.log(`本地代理服务器已启动,监听端口: ${PORT}`);
|
||||||
|
console.log('====================================');
|
||||||
|
if (!process.env.ALIYUN_API_KEY) {
|
||||||
|
console.log('⚠️ 警告: 未在 .env 文件中检测到 ALIYUN_API_KEY!');
|
||||||
|
console.log('请在 server/.env 中添加您的百炼 API Key。');
|
||||||
|
} else {
|
||||||
|
console.log('✅ 检测到了 API Key。');
|
||||||
|
}
|
||||||
|
});
|
||||||
448
src/App.vue
448
src/App.vue
|
|
@ -1,225 +1,225 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="app" :class="{ 'dark': isDark }">
|
<div class="app" :class="{ 'dark': isDark }">
|
||||||
<!-- 侧边栏 -->
|
<!-- 侧边栏 -->
|
||||||
<ChatSidebar />
|
<ChatSidebar />
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
<!-- 主内容区 -->
|
||||||
<ChatMain
|
<ChatMain
|
||||||
ref="chatMainRef"
|
ref="chatMainRef"
|
||||||
@toggle-sidebar="toggleSidebar"
|
@toggle-sidebar="toggleSidebar"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 模态框 -->
|
<!-- 模态框 -->
|
||||||
<SearchModal />
|
<SearchModal />
|
||||||
<ShortcutsModal />
|
<ShortcutsModal />
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<ConversationSettingsModal />
|
<ConversationSettingsModal />
|
||||||
|
|
||||||
<!-- Toast 通知 -->
|
<!-- Toast 通知 -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<TransitionGroup name="toast" tag="div" class="toast-container">
|
<TransitionGroup name="toast" tag="div" class="toast-container">
|
||||||
<div
|
<div
|
||||||
v-for="toast in toasts"
|
v-for="toast in toasts"
|
||||||
:key="toast.id"
|
:key="toast.id"
|
||||||
class="toast"
|
class="toast"
|
||||||
:class="toast.type"
|
:class="toast.type"
|
||||||
>
|
>
|
||||||
<Check v-if="toast.type === 'success'" :size="18" />
|
<Check v-if="toast.type === 'success'" :size="18" />
|
||||||
<AlertCircle v-else-if="toast.type === 'error'" :size="18" />
|
<AlertCircle v-else-if="toast.type === 'error'" :size="18" />
|
||||||
<Info v-else :size="18" />
|
<Info v-else :size="18" />
|
||||||
<span>{{ toast.message }}</span>
|
<span>{{ toast.message }}</span>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { useKeyboard, getDefaultShortcuts } from '@/composables/useKeyboard'
|
import { useKeyboard, getDefaultShortcuts } from '@/composables/useKeyboard'
|
||||||
import ChatSidebar from '@/components/sidebar/ChatSidebar.vue'
|
import ChatSidebar from '@/components/sidebar/ChatSidebar.vue'
|
||||||
import ChatMain from '@/components/chat/ChatMain.vue'
|
import ChatMain from '@/components/chat/ChatMain.vue'
|
||||||
import SearchModal from '@/components/modals/SearchModal.vue'
|
import SearchModal from '@/components/modals/SearchModal.vue'
|
||||||
import ShortcutsModal from '@/components/modals/ShortcutsModal.vue'
|
import ShortcutsModal from '@/components/modals/ShortcutsModal.vue'
|
||||||
import SettingsModal from '@/components/modals/SettingsModal.vue'
|
import SettingsModal from '@/components/modals/SettingsModal.vue'
|
||||||
import ConversationSettingsModal from '@/components/modals/ConversationSettingsModal.vue'
|
import ConversationSettingsModal from '@/components/modals/ConversationSettingsModal.vue'
|
||||||
import { Check, AlertCircle, Info } from '@/components/icons'
|
import { Check, AlertCircle, Info } from '@/components/icons'
|
||||||
|
|
||||||
// Stores
|
// Stores
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
const { settings } = storeToRefs(settingsStore)
|
const { settings } = storeToRefs(settingsStore)
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null)
|
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null)
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const isDark = computed(() => {
|
const isDark = computed(() => {
|
||||||
if (settings.value.theme === 'system') {
|
if (settings.value.theme === 'system') {
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
}
|
}
|
||||||
return settings.value.theme === 'dark'
|
return settings.value.theme === 'dark'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Toast 通知系统
|
// Toast 通知系统
|
||||||
interface Toast {
|
interface Toast {
|
||||||
id: number
|
id: number
|
||||||
message: string
|
message: string
|
||||||
type: 'success' | 'error' | 'info'
|
type: 'success' | 'error' | 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
const toasts = ref<Toast[]>([])
|
const toasts = ref<Toast[]>([])
|
||||||
let toastId = 0
|
let toastId = 0
|
||||||
|
|
||||||
function showToast(message: string, type: Toast['type'] = 'info') {
|
function showToast(message: string, type: Toast['type'] = 'info') {
|
||||||
const id = ++toastId
|
const id = ++toastId
|
||||||
toasts.value.push({ id, message, type })
|
toasts.value.push({ id, message, type })
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const index = toasts.value.findIndex(t => t.id === id)
|
const index = toasts.value.findIndex(t => t.id === id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
toasts.value.splice(index, 1)
|
toasts.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
settingsStore.toggleSidebar()
|
settingsStore.toggleSidebar()
|
||||||
}
|
}
|
||||||
|
|
||||||
function newChat() {
|
function newChat() {
|
||||||
chatStore.createConversation()
|
chatStore.createConversation()
|
||||||
showToast('已创建新对话', 'success')
|
showToast('已创建新对话', 'success')
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusInput() {
|
function focusInput() {
|
||||||
chatMainRef.value?.focusInput()
|
chatMainRef.value?.focusInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 快捷键
|
// 快捷键
|
||||||
useKeyboard(
|
useKeyboard(
|
||||||
getDefaultShortcuts({
|
getDefaultShortcuts({
|
||||||
newChat,
|
newChat,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
focusInput,
|
focusInput,
|
||||||
sendMessage: () => {}, // 由 ChatInput 内部处理
|
sendMessage: () => {}, // 由 ChatInput 内部处理
|
||||||
cancelStream: () => {
|
cancelStream: () => {
|
||||||
if (chatStore.isStreaming) {
|
if (chatStore.isStreaming) {
|
||||||
chatStore.stopStreaming()
|
chatStore.stopStreaming()
|
||||||
showToast('已停止生成', 'info')
|
showToast('已停止生成', 'info')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleTheme: () => {
|
toggleTheme: () => {
|
||||||
settingsStore.toggleTheme()
|
settingsStore.toggleTheme()
|
||||||
showToast(`主题已切换为 ${settings.value.theme}`, 'success')
|
showToast(`主题已切换为 ${settings.value.theme}`, 'success')
|
||||||
},
|
},
|
||||||
showShortcuts: () => {
|
showShortcuts: () => {
|
||||||
settingsStore.openShortcutsModal()
|
settingsStore.openShortcutsModal()
|
||||||
},
|
},
|
||||||
searchConversations: () => {
|
searchConversations: () => {
|
||||||
settingsStore.openSearchModal()
|
settingsStore.openSearchModal()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 如果没有对话,创建一个
|
// 如果没有对话,创建一个
|
||||||
if (chatStore.conversations.length === 0) {
|
if (chatStore.conversations.length === 0) {
|
||||||
chatStore.createConversation()
|
chatStore.createConversation()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 暴露给全局使用
|
// 暴露给全局使用
|
||||||
window.$toast = showToast
|
window.$toast = showToast
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.app {
|
.app {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
|
||||||
&.dark {
|
&.dark {
|
||||||
background: #11111b;
|
background: #11111b;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toast 样式
|
// Toast 样式
|
||||||
.toast-container {
|
.toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 14px 20px;
|
padding: 14px 20px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #2d2d3d;
|
background: #2d2d3d;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.success {
|
&.success {
|
||||||
svg {
|
svg {
|
||||||
color: #10b981;
|
color: #10b981;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
&.error {
|
||||||
svg {
|
svg {
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.info {
|
&.info {
|
||||||
svg {
|
svg {
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toast 动画
|
// Toast 动画
|
||||||
.toast-enter-active,
|
.toast-enter-active,
|
||||||
.toast-leave-active {
|
.toast-leave-active {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-enter-from {
|
.toast-enter-from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(100px);
|
transform: translateX(100px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-leave-to {
|
.toast-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(100px);
|
transform: translateX(100px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-move {
|
.toast-move {
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,320 +1,338 @@
|
||||||
<template>
|
<template>
|
||||||
<main class="chat-main" :class="{ 'wide-mode': isWideMode }">
|
<main class="chat-main" :class="{ 'wide-mode': isWideMode }">
|
||||||
<!-- 头部 -->
|
<!-- 头部 -->
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
:title="currentConversation?.title || '新对话'"
|
:title="currentConversation?.title || '新对话'"
|
||||||
:message-count="messages.length"
|
:message-count="messages.length"
|
||||||
:show-sidebar-toggle="sidebarCollapsed"
|
:show-sidebar-toggle="sidebarCollapsed"
|
||||||
:is-wide-mode="isWideMode"
|
:is-wide-mode="isWideMode"
|
||||||
:is-pinned="currentConversation?.pinned"
|
:is-pinned="currentConversation?.pinned"
|
||||||
@toggle-sidebar="$emit('toggle-sidebar')"
|
@toggle-sidebar="$emit('toggle-sidebar')"
|
||||||
@toggle-wide-mode="toggleWideMode"
|
@toggle-wide-mode="toggleWideMode"
|
||||||
@clear="handleClear"
|
@clear="handleClear"
|
||||||
@export="handleExport"
|
@export="handleExport"
|
||||||
@pin="handlePin"
|
@pin="handlePin"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<MessageList
|
<MessageList
|
||||||
ref="messageListRef"
|
ref="messageListRef"
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:show-timestamp="settings.showTimestamp"
|
:show-timestamp="settings.showTimestamp"
|
||||||
:compact="settings.compactMode"
|
:compact="settings.compactMode"
|
||||||
:is-typing="isTyping"
|
:is-typing="isTyping"
|
||||||
@retry="handleRetry"
|
@retry="handleRetry"
|
||||||
@regenerate="handleRegenerate"
|
@regenerate="handleRegenerate"
|
||||||
@select-suggestion="handleSuggestion"
|
@select-suggestion="handleSuggestion"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 输入区域 -->
|
<!-- 输入区域 -->
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<div class="input-container" :class="{ wide: isWideMode }">
|
<div class="input-container" :class="{ wide: isWideMode }">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
ref="chatInputRef"
|
ref="chatInputRef"
|
||||||
:placeholder="inputPlaceholder"
|
:placeholder="inputPlaceholder"
|
||||||
:is-streaming="isStreaming"
|
:is-streaming="isStreaming"
|
||||||
:send-on-enter="settings.sendOnEnter"
|
:send-on-enter="settings.sendOnEnter"
|
||||||
:disabled="false"
|
:disabled="false"
|
||||||
@send="handleSend"
|
@send="handleSend"
|
||||||
@stop="handleStop"
|
@stop="handleStop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from "vue";
|
import { ref, computed, watch, nextTick } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import ChatHeader from "./ChatHeader.vue";
|
import ChatHeader from "./ChatHeader.vue";
|
||||||
import MessageList from "./MessageList.vue";
|
import MessageList from "./MessageList.vue";
|
||||||
import ChatInput from "@/components/input/ChatInput.vue";
|
import ChatInput from "@/components/input/ChatInput.vue";
|
||||||
import { MessageType, MessageRole } from "@/types/chat";
|
import { MessageType, MessageRole } from "@/types/chat";
|
||||||
import type { Attachment } from "@/types/chat";
|
import type { Attachment } from "@/types/chat";
|
||||||
import { chatApi } from "@/services/api.ts";
|
import { chatApi } from "@/services/api";
|
||||||
import { streamAIResponse, generateSuggestions } from "@/services/mockAI";
|
|
||||||
|
defineEmits<{
|
||||||
defineEmits<{
|
"toggle-sidebar": [];
|
||||||
"toggle-sidebar": [];
|
}>();
|
||||||
}>();
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
const chatStore = useChatStore();
|
const settingsStore = useSettingsStore();
|
||||||
const settingsStore = useSettingsStore();
|
|
||||||
|
const { currentConversation, isStreaming } = storeToRefs(chatStore);
|
||||||
const { currentConversation, isStreaming } = storeToRefs(chatStore);
|
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
|
||||||
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
|
|
||||||
|
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
|
||||||
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
|
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
||||||
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
const isWideMode = ref(true);
|
||||||
const isWideMode = ref(true);
|
const isTyping = ref(false);
|
||||||
const isTyping = ref(false);
|
const currentStreamingMessageId = ref<string | null>(null);
|
||||||
const currentStreamingMessageId = ref<string | null>(null);
|
const abortController: any = ref<AbortController | null>(null);
|
||||||
const abortController: any = ref<AbortController | null>(null);
|
|
||||||
|
const messages: any = computed(() => currentConversation.value?.messages || []);
|
||||||
const messages: any = computed(() => currentConversation.value?.messages || []);
|
|
||||||
|
const inputPlaceholder = computed(() => {
|
||||||
const inputPlaceholder = computed(() => {
|
if (isStreaming.value) return "正在生成回复...";
|
||||||
if (isStreaming.value) return "正在生成回复...";
|
return "输入你的问题,按 Ctrl+Enter 发送";
|
||||||
return "输入你的问题,按 Ctrl+Enter 发送";
|
});
|
||||||
});
|
|
||||||
|
function toggleWideMode() {
|
||||||
function toggleWideMode() {
|
isWideMode.value = !isWideMode.value;
|
||||||
isWideMode.value = !isWideMode.value;
|
}
|
||||||
}
|
|
||||||
|
function handleClear() {
|
||||||
function handleClear() {
|
if (currentConversation.value) {
|
||||||
if (currentConversation.value) {
|
chatStore.clearConversation(currentConversation.value.id);
|
||||||
chatStore.clearConversation(currentConversation.value.id);
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
function handleExport() {
|
||||||
function handleExport() {
|
if (!currentConversation.value) return;
|
||||||
if (!currentConversation.value) return;
|
|
||||||
|
const data = {
|
||||||
const data = {
|
title: currentConversation.value.title,
|
||||||
title: currentConversation.value.title,
|
messages: currentConversation.value.messages,
|
||||||
messages: currentConversation.value.messages,
|
exportedAt: new Date().toISOString(),
|
||||||
exportedAt: new Date().toISOString(),
|
};
|
||||||
};
|
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
type: "application/json",
|
||||||
type: "application/json",
|
});
|
||||||
});
|
const url = URL.createObjectURL(blob);
|
||||||
const url = URL.createObjectURL(blob);
|
const a = document.createElement("a");
|
||||||
const a = document.createElement("a");
|
a.href = url;
|
||||||
a.href = url;
|
a.download = `${currentConversation.value.title}.json`;
|
||||||
a.download = `${currentConversation.value.title}.json`;
|
a.click();
|
||||||
a.click();
|
URL.revokeObjectURL(url);
|
||||||
URL.revokeObjectURL(url);
|
}
|
||||||
}
|
|
||||||
|
function handlePin() {
|
||||||
function handlePin() {
|
if (currentConversation.value) {
|
||||||
if (currentConversation.value) {
|
chatStore.togglePinConversation(currentConversation.value.id);
|
||||||
chatStore.togglePinConversation(currentConversation.value.id);
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// 发送消息 - 使用真实 API
|
||||||
// 发送消息 - 使用真实 API
|
async function handleSend(text: string, attachments: Attachment[]) {
|
||||||
async function handleSend(text: string, attachments: Attachment[]) {
|
// 如果没有当前对话,创建新对话
|
||||||
// 如果没有当前对话,创建新对话
|
if (!currentConversation.value) {
|
||||||
if (!currentConversation.value) {
|
chatStore.createConversation();
|
||||||
chatStore.createConversation();
|
}
|
||||||
}
|
|
||||||
|
// 添加用户消息
|
||||||
// 添加用户消息
|
chatStore.addMessage(MessageRole.USER, {
|
||||||
chatStore.addMessage(MessageRole.USER, {
|
type: MessageType.TEXT,
|
||||||
type: MessageType.TEXT,
|
text,
|
||||||
text,
|
images: attachments.filter((a) => a.type === "image"),
|
||||||
images: attachments.filter((a) => a.type === "image"),
|
files: attachments.filter((a) => a.type === "file"),
|
||||||
files: attachments.filter((a) => a.type === "file"),
|
});
|
||||||
});
|
|
||||||
|
// 添加 AI 消息占位符
|
||||||
// 添加 AI 消息占位符
|
const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, {
|
||||||
const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, {
|
type: MessageType.TEXT,
|
||||||
type: MessageType.TEXT,
|
text: "",
|
||||||
text: "",
|
});
|
||||||
});
|
|
||||||
|
currentStreamingMessageId.value = aiMessage.id;
|
||||||
currentStreamingMessageId.value = aiMessage.id;
|
chatStore.updateMessage(aiMessage.id, { isStreaming: true });
|
||||||
chatStore.updateMessage(aiMessage.id, { isStreaming: true });
|
chatStore.startStreaming();
|
||||||
chatStore.startStreaming();
|
isTyping.value = true;
|
||||||
isTyping.value = true;
|
|
||||||
|
// 创建 AbortController
|
||||||
// 创建 AbortController
|
abortController.value = new AbortController();
|
||||||
abortController.value = new AbortController();
|
try {
|
||||||
await streamAIResponse(
|
const stream = chatApi.streamChat(
|
||||||
text,
|
{
|
||||||
{
|
message: text,
|
||||||
onStart: () => {
|
conversationId: currentConversation.value?.id || "",
|
||||||
isTyping.value = false;
|
model: settings.value.defaultModel,
|
||||||
},
|
stream: true,
|
||||||
onToken: (_token, fullText) => {
|
},
|
||||||
chatStore.updateMessageContent(aiMessage.id, fullText);
|
abortController.value.signal,
|
||||||
},
|
);
|
||||||
onComplete: (fullText) => {
|
|
||||||
chatStore.updateMessage(aiMessage.id, {
|
let fullText = "";
|
||||||
isStreaming: false,
|
isTyping.value = false;
|
||||||
content: {
|
|
||||||
type: MessageType.TEXT,
|
for await (const chunk of stream) {
|
||||||
text: fullText,
|
if (abortController.value?.signal.aborted) break;
|
||||||
suggestions: generateSuggestions(),
|
fullText += chunk;
|
||||||
},
|
chatStore.updateMessageContent(aiMessage.id, fullText);
|
||||||
});
|
}
|
||||||
chatStore.stopStreaming();
|
|
||||||
currentStreamingMessageId.value = null;
|
if (!abortController.value?.signal.aborted) {
|
||||||
},
|
chatStore.updateMessage(aiMessage.id, {
|
||||||
onError: (error) => {
|
isStreaming: false,
|
||||||
chatStore.updateMessage(aiMessage.id, {
|
content: {
|
||||||
isStreaming: false,
|
type: MessageType.TEXT,
|
||||||
isError: true,
|
text: fullText,
|
||||||
errorMessage: error.message,
|
},
|
||||||
});
|
});
|
||||||
chatStore.stopStreaming();
|
}
|
||||||
currentStreamingMessageId.value = null;
|
} catch (error: any) {
|
||||||
},
|
if (error.name !== "AbortError") {
|
||||||
},
|
chatStore.updateMessage(aiMessage.id, {
|
||||||
chatStore.streamController?.signal,
|
isStreaming: false,
|
||||||
);
|
isError: true,
|
||||||
}
|
errorMessage: error.message || "请求失败",
|
||||||
|
});
|
||||||
// 停止生成
|
}
|
||||||
function handleStop() {
|
} finally {
|
||||||
if (abortController.value) {
|
chatStore.stopStreaming();
|
||||||
abortController.value.abort();
|
currentStreamingMessageId.value = null;
|
||||||
abortController.value = null;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chatStore.stopStreaming();
|
// 停止生成
|
||||||
chatApi.stopChat(messages.value.at(-1)["messageId"]);
|
function handleStop() {
|
||||||
if (currentStreamingMessageId.value) {
|
if (abortController.value) {
|
||||||
chatStore.updateMessage(currentStreamingMessageId.value, {
|
abortController.value.abort();
|
||||||
isStreaming: false,
|
abortController.value = null;
|
||||||
});
|
}
|
||||||
currentStreamingMessageId.value = null;
|
|
||||||
}
|
chatStore.stopStreaming();
|
||||||
}
|
chatApi.stopChat(messages.value.at(-1)["messageId"]);
|
||||||
|
if (currentStreamingMessageId.value) {
|
||||||
// 重试
|
chatStore.updateMessage(currentStreamingMessageId.value, {
|
||||||
async function handleRetry(messageId: string) {
|
isStreaming: false,
|
||||||
const message = messages.value.find((m: any) => m.id === messageId);
|
});
|
||||||
if (!message || message.role !== MessageRole.ASSISTANT) return;
|
currentStreamingMessageId.value = null;
|
||||||
|
}
|
||||||
const messageIndex = messages.value.findIndex((m: any) => m.id === messageId);
|
}
|
||||||
if (messageIndex <= 0) return;
|
|
||||||
|
// 重试
|
||||||
const userMessage = messages.value[messageIndex - 1];
|
async function handleRetry(messageId: string) {
|
||||||
if (userMessage.role !== MessageRole.USER) return;
|
const message = messages.value.find((m: any) => m.id === messageId);
|
||||||
|
if (!message || message.role !== MessageRole.ASSISTANT) return;
|
||||||
// 重置消息状态
|
|
||||||
chatStore.updateMessage(messageId, {
|
const messageIndex = messages.value.findIndex((m: any) => m.id === messageId);
|
||||||
isError: false,
|
if (messageIndex <= 0) return;
|
||||||
errorMessage: undefined,
|
|
||||||
isStreaming: true,
|
const userMessage = messages.value[messageIndex - 1];
|
||||||
isEnd: true,
|
if (userMessage.role !== MessageRole.USER) return;
|
||||||
content: { type: MessageType.TEXT, text: "" },
|
|
||||||
});
|
// 重置消息状态
|
||||||
|
chatStore.updateMessage(messageId, {
|
||||||
currentStreamingMessageId.value = messageId;
|
isError: false,
|
||||||
chatStore.startStreaming();
|
errorMessage: undefined,
|
||||||
abortController.value = new AbortController();
|
isStreaming: true,
|
||||||
await streamAIResponse(
|
isEnd: true,
|
||||||
userMessage.content.text || "",
|
content: { type: MessageType.TEXT, text: "" },
|
||||||
{
|
});
|
||||||
onToken: (_token, fullText) => {
|
|
||||||
chatStore.updateMessageContent(messageId, fullText);
|
currentStreamingMessageId.value = messageId;
|
||||||
},
|
chatStore.startStreaming();
|
||||||
onComplete: (fullText) => {
|
abortController.value = new AbortController();
|
||||||
chatStore.updateMessage(messageId, {
|
|
||||||
isStreaming: false,
|
try {
|
||||||
content: {
|
const stream = chatApi.streamChat(
|
||||||
type: MessageType.TEXT,
|
{
|
||||||
text: fullText,
|
message: userMessage.content.text || "",
|
||||||
suggestions: generateSuggestions(),
|
conversationId: currentConversation.value?.id,
|
||||||
},
|
model: settings.value.defaultModel,
|
||||||
});
|
stream: true,
|
||||||
chatStore.stopStreaming();
|
},
|
||||||
currentStreamingMessageId.value = null;
|
abortController.value.signal,
|
||||||
},
|
);
|
||||||
onError: (error) => {
|
|
||||||
chatStore.updateMessage(messageId, {
|
let fullText = "";
|
||||||
isStreaming: false,
|
|
||||||
isError: true,
|
for await (const chunk of stream) {
|
||||||
errorMessage: error.message,
|
if (abortController.value?.signal.aborted) break;
|
||||||
});
|
fullText += chunk;
|
||||||
chatStore.stopStreaming();
|
chatStore.updateMessageContent(messageId, fullText);
|
||||||
currentStreamingMessageId.value = null;
|
}
|
||||||
},
|
|
||||||
},
|
if (!abortController.value?.signal.aborted) {
|
||||||
chatStore.streamController?.signal,
|
chatStore.updateMessage(messageId, {
|
||||||
);
|
isStreaming: false,
|
||||||
}
|
content: {
|
||||||
|
type: MessageType.TEXT,
|
||||||
function handleRegenerate(messageId: string) {
|
text: fullText,
|
||||||
handleRetry(messageId);
|
},
|
||||||
}
|
});
|
||||||
|
}
|
||||||
function handleSuggestion(text: string) {
|
} catch (error: any) {
|
||||||
handleSend(text, []);
|
if (error.name !== "AbortError") {
|
||||||
}
|
chatStore.updateMessage(messageId, {
|
||||||
|
isStreaming: false,
|
||||||
function focusInput() {
|
isError: true,
|
||||||
chatInputRef.value?.focus();
|
errorMessage: error.message || "请求失败",
|
||||||
}
|
});
|
||||||
|
}
|
||||||
defineExpose({
|
} finally {
|
||||||
focusInput,
|
chatStore.stopStreaming();
|
||||||
messageListRef,
|
currentStreamingMessageId.value = null;
|
||||||
});
|
}
|
||||||
|
}
|
||||||
watch(
|
|
||||||
() => currentConversation.value?.id,
|
function handleRegenerate(messageId: string) {
|
||||||
() => {
|
handleRetry(messageId);
|
||||||
nextTick(() => {
|
}
|
||||||
focusInput();
|
|
||||||
});
|
function handleSuggestion(text: string) {
|
||||||
},
|
handleSend(text, []);
|
||||||
);
|
}
|
||||||
</script>
|
|
||||||
|
function focusInput() {
|
||||||
<style lang="scss" scoped>
|
chatInputRef.value?.focus();
|
||||||
.chat-main {
|
}
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
defineExpose({
|
||||||
flex: 1;
|
focusInput,
|
||||||
height: 100vh;
|
messageListRef,
|
||||||
background: #ffffff;
|
});
|
||||||
overflow: hidden;
|
|
||||||
|
watch(
|
||||||
.dark & {
|
() => currentConversation.value?.id,
|
||||||
background: #11111b;
|
() => {
|
||||||
}
|
nextTick(() => {
|
||||||
|
focusInput();
|
||||||
&.wide-mode {
|
});
|
||||||
.input-container {
|
},
|
||||||
max-width: 1000px;
|
);
|
||||||
}
|
</script>
|
||||||
}
|
|
||||||
}
|
<style lang="scss" scoped>
|
||||||
|
.chat-main {
|
||||||
.input-wrapper {
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-direction: column;
|
||||||
padding: 16px 24px 24px;
|
flex: 1;
|
||||||
background: linear-gradient(to top, white 80%, transparent);
|
height: 100vh;
|
||||||
|
background: #ffffff;
|
||||||
.dark & {
|
overflow: hidden;
|
||||||
background: linear-gradient(to top, #11111b 80%, transparent);
|
|
||||||
}
|
.dark & {
|
||||||
}
|
background: #11111b;
|
||||||
|
}
|
||||||
.input-container {
|
|
||||||
max-width: 800px;
|
&.wide-mode {
|
||||||
margin: 0 auto;
|
.input-container {
|
||||||
transition: max-width 0.3s ease;
|
max-width: 1000px;
|
||||||
|
}
|
||||||
&.wide {
|
}
|
||||||
max-width: 1000px;
|
}
|
||||||
}
|
|
||||||
}
|
.input-wrapper {
|
||||||
</style>
|
flex-shrink: 0;
|
||||||
|
padding: 16px 24px 24px;
|
||||||
|
background: linear-gradient(to top, white 80%, transparent);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: linear-gradient(to top, #11111b 80%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
transition: max-width 0.3s ease;
|
||||||
|
|
||||||
|
&.wide {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,397 +1,397 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="boxRef" style="flex: 1; position: relative">
|
<div ref="boxRef" style="flex: 1; position: relative">
|
||||||
<div ref="containerRef" class="message-list" @scroll="handleScroll">
|
<div ref="containerRef" class="message-list" @scroll="handleScroll">
|
||||||
<!-- 欢迎界面 -->
|
<!-- 欢迎界面 -->
|
||||||
<WelcomeScreen
|
<WelcomeScreen
|
||||||
v-if="messages.length === 0"
|
v-if="messages.length === 0"
|
||||||
@select="$emit('select-suggestion', $event)"
|
@select="$emit('select-suggestion', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="messages-wrapper">
|
<div class="messages-wrapper">
|
||||||
<TransitionGroup name="message">
|
<TransitionGroup name="message">
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
v-for="(message, index) in messages"
|
v-for="(message, index) in messages"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
:message="message"
|
:message="message"
|
||||||
:show-timestamp="showTimestamp"
|
:show-timestamp="showTimestamp"
|
||||||
:compact="compact"
|
:compact="compact"
|
||||||
:is-New="index === messages.length - 1"
|
:is-New="index === messages.length - 1"
|
||||||
@retry="$emit('retry', message.id)"
|
@retry="$emit('retry', message.id)"
|
||||||
@regenerate="$emit('regenerate', message.id)"
|
@regenerate="$emit('regenerate', message.id)"
|
||||||
@copy="handleCopy(message)"
|
@copy="handleCopy(message)"
|
||||||
@like="handleLike(message)"
|
@like="handleLike(message)"
|
||||||
@dislike="handleDislike(message)"
|
@dislike="handleDislike(message)"
|
||||||
@select-suggestion="$emit('select-suggestion', $event.text)"
|
@select-suggestion="$emit('select-suggestion', $event.text)"
|
||||||
@preview-image="handlePreviewImage"
|
@preview-image="handlePreviewImage"
|
||||||
@play-video="handlePlayVideo"
|
@play-video="handlePlayVideo"
|
||||||
@download-file="handleDownloadFile"
|
@download-file="handleDownloadFile"
|
||||||
/>
|
/>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|
||||||
<!-- 正在输入指示器 -->
|
<!-- 正在输入指示器 -->
|
||||||
<div v-if="isTyping" class="typing-indicator">
|
<div v-if="isTyping" class="typing-indicator">
|
||||||
<div class="typing-avatar">
|
<div class="typing-avatar">
|
||||||
<Bot :size="20" />
|
<Bot :size="20" />
|
||||||
</div>
|
</div>
|
||||||
<div class="typing-dots">
|
<div class="typing-dots">
|
||||||
<span></span>
|
<span></span>
|
||||||
<span></span>
|
<span></span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="typing-text">AI 正在思考...</span>
|
<span class="typing-text">AI 正在思考...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<!-- 回到底部按钮 -->
|
<!-- 回到底部按钮 -->
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<button
|
<button
|
||||||
v-if="showScrollButton"
|
v-if="showScrollButton"
|
||||||
class="scroll-bottom-btn"
|
class="scroll-bottom-btn"
|
||||||
@click="handleScrollToBottom"
|
@click="handleScrollToBottom"
|
||||||
>
|
>
|
||||||
<ChevronDown :size="20" />
|
<ChevronDown :size="20" />
|
||||||
<span v-if="newMessageCount > 0" class="new-count">
|
<span v-if="newMessageCount > 0" class="new-count">
|
||||||
{{ newMessageCount }}
|
{{ newMessageCount }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, onMounted } from "vue";
|
import { ref, watch, nextTick, onMounted } from "vue";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import MessageBubble from "@/components/message/MessageBubble.vue";
|
import MessageBubble from "@/components/message/MessageBubble.vue";
|
||||||
import WelcomeScreen from "./WelcomeScreen.vue";
|
import WelcomeScreen from "./WelcomeScreen.vue";
|
||||||
import { Bot, ChevronDown } from "@/components/icons";
|
import { Bot, ChevronDown } from "@/components/icons";
|
||||||
import type { Message, Attachment, VideoInfo } from "@/types/chat";
|
import type { Message, Attachment, VideoInfo } from "@/types/chat";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
showTimestamp?: boolean;
|
showTimestamp?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
isTyping?: boolean;
|
isTyping?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
showTimestamp: true,
|
showTimestamp: true,
|
||||||
compact: false,
|
compact: false,
|
||||||
isTyping: false,
|
isTyping: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
retry: [messageId: string];
|
retry: [messageId: string];
|
||||||
regenerate: [messageId: string];
|
regenerate: [messageId: string];
|
||||||
"select-suggestion": [text: string];
|
"select-suggestion": [text: string];
|
||||||
"preview-image": [image: Attachment, index: number];
|
"preview-image": [image: Attachment, index: number];
|
||||||
"play-video": [video: VideoInfo];
|
"play-video": [video: VideoInfo];
|
||||||
"download-file": [file: Attachment];
|
"download-file": [file: Attachment];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
// 响应式状态
|
// 响应式状态
|
||||||
const boxRef: any = ref<HTMLElement | null>(null);
|
const boxRef: any = ref<HTMLElement | null>(null);
|
||||||
const containerRef: any = ref<HTMLElement | null>(null);
|
const containerRef: any = ref<HTMLElement | null>(null);
|
||||||
const showScrollButton = ref(false);
|
const showScrollButton = ref(false);
|
||||||
const newMessageCount = ref(0);
|
const newMessageCount = ref(0);
|
||||||
const isAutoScrolling = ref(true);
|
const isAutoScrolling = ref(true);
|
||||||
const lastScrollTop = ref(0);
|
const lastScrollTop = ref(0);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
containerRef.value.style.height = boxRef.value?.clientHeight + "px";
|
containerRef.value.style.height = boxRef.value?.clientHeight + "px";
|
||||||
});
|
});
|
||||||
|
|
||||||
// 滚动处理
|
// 滚动处理
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const container = containerRef.value;
|
const container = containerRef.value;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
|
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
|
||||||
|
|
||||||
if (scrollTop < lastScrollTop.value && !isAtBottom) {
|
if (scrollTop < lastScrollTop.value && !isAtBottom) {
|
||||||
isAutoScrolling.value = false;
|
isAutoScrolling.value = false;
|
||||||
showScrollButton.value = true;
|
showScrollButton.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAtBottom) {
|
if (isAtBottom) {
|
||||||
isAutoScrolling.value = true;
|
isAutoScrolling.value = true;
|
||||||
showScrollButton.value = false;
|
showScrollButton.value = false;
|
||||||
newMessageCount.value = 0;
|
newMessageCount.value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastScrollTop.value = scrollTop;
|
lastScrollTop.value = scrollTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
function scrollToBottom(smooth = true) {
|
function scrollToBottom(smooth = true) {
|
||||||
const container = containerRef.value;
|
const container = containerRef.value;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
container.scrollTo({
|
container.scrollTo({
|
||||||
top: container.scrollHeight,
|
top: container.scrollHeight,
|
||||||
behavior: smooth ? "smooth" : "auto",
|
behavior: smooth ? "smooth" : "auto",
|
||||||
});
|
});
|
||||||
|
|
||||||
isAutoScrolling.value = true;
|
isAutoScrolling.value = true;
|
||||||
showScrollButton.value = false;
|
showScrollButton.value = false;
|
||||||
newMessageCount.value = 0;
|
newMessageCount.value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按钮点击处理
|
// 按钮点击处理
|
||||||
function handleScrollToBottom() {
|
function handleScrollToBottom() {
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息操作
|
// 消息操作
|
||||||
function handleCopy(message: Message) {
|
function handleCopy(message: Message) {
|
||||||
chatStore.setMessageCopied(message.id);
|
chatStore.setMessageCopied(message.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLike(message: Message) {
|
function handleLike(message: Message) {
|
||||||
const currentLiked = message.feedback?.liked;
|
const currentLiked = message.feedback?.liked;
|
||||||
chatStore.setMessageFeedback(message.id, currentLiked ? null : "like");
|
chatStore.setMessageFeedback(message.id, currentLiked ? null : "like");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDislike(message: Message) {
|
function handleDislike(message: Message) {
|
||||||
const currentDisliked = message.feedback?.disliked;
|
const currentDisliked = message.feedback?.disliked;
|
||||||
chatStore.setMessageFeedback(message.id, currentDisliked ? null : "dislike");
|
chatStore.setMessageFeedback(message.id, currentDisliked ? null : "dislike");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePreviewImage(image: Attachment, index: number) {
|
function handlePreviewImage(image: Attachment, index: number) {
|
||||||
emit("preview-image", image, index);
|
emit("preview-image", image, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlayVideo(video: VideoInfo) {
|
function handlePlayVideo(video: VideoInfo) {
|
||||||
emit("play-video", video);
|
emit("play-video", video);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDownloadFile(file: Attachment) {
|
function handleDownloadFile(file: Attachment) {
|
||||||
emit("download-file", file);
|
emit("download-file", file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听消息变化
|
// 监听消息变化
|
||||||
watch(
|
watch(
|
||||||
() => props.messages.length,
|
() => props.messages.length,
|
||||||
(newLen, oldLen) => {
|
(newLen, oldLen) => {
|
||||||
if (newLen > oldLen) {
|
if (newLen > oldLen) {
|
||||||
if (isAutoScrolling.value) {
|
if (isAutoScrolling.value) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
scrollToBottom(false);
|
scrollToBottom(false);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
newMessageCount.value++;
|
newMessageCount.value++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 监听最后一条消息的内容变化
|
// 监听最后一条消息的内容变化
|
||||||
watch(
|
watch(
|
||||||
() => props.messages[props.messages.length - 1]?.content.text,
|
() => props.messages[props.messages.length - 1]?.content.text,
|
||||||
() => {
|
() => {
|
||||||
if (isAutoScrolling.value) {
|
if (isAutoScrolling.value) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const container = containerRef.value;
|
const container = containerRef.value;
|
||||||
if (container) {
|
if (container) {
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 监听 isTyping 变化
|
// 监听 isTyping 变化
|
||||||
watch(
|
watch(
|
||||||
() => props.isTyping,
|
() => props.isTyping,
|
||||||
(typing) => {
|
(typing) => {
|
||||||
if (typing && isAutoScrolling.value) {
|
if (typing && isAutoScrolling.value) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 暴露方法
|
// 暴露方法
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
scrollToBottom(false);
|
scrollToBottom(false);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.message-list {
|
.message-list {
|
||||||
height: 500px;
|
height: 500px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-wrapper {
|
.messages-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.typing-indicator {
|
.typing-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
animation: fadeIn 0.3s ease;
|
animation: fadeIn 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.typing-avatar {
|
.typing-avatar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.typing-dots {
|
.typing-dots {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #2d2d3d;
|
background: #2d2d3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: #9ca3af;
|
background: #9ca3af;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: typingBounce 1.4s infinite ease-in-out both;
|
animation: typingBounce 1.4s infinite ease-in-out both;
|
||||||
|
|
||||||
&:nth-child(1) {
|
&:nth-child(1) {
|
||||||
animation-delay: -0.32s;
|
animation-delay: -0.32s;
|
||||||
}
|
}
|
||||||
&:nth-child(2) {
|
&:nth-child(2) {
|
||||||
animation-delay: -0.16s;
|
animation-delay: -0.16s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.typing-text {
|
.typing-text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-bottom-btn {
|
.scroll-bottom-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: white;
|
background: white;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #2d2d3d;
|
background: #2d2d3d;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateX(-50%) scale(1.1);
|
transform: translateX(-50%) scale(1.1);
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-count {
|
.new-count {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
right: -4px;
|
right: -4px;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息过渡动画
|
// 消息过渡动画
|
||||||
.message-enter-active,
|
.message-enter-active,
|
||||||
.message-leave-active {
|
.message-leave-active {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-enter-from {
|
.message-enter-from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translateY(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-leave-to {
|
.message-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-20px);
|
transform: translateX(-20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 淡入淡出动画
|
// 淡入淡出动画
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter-from,
|
.fade-enter-from,
|
||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-50%) translateY(10px);
|
transform: translateX(-50%) translateY(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes typingBounce {
|
@keyframes typingBounce {
|
||||||
0%,
|
0%,
|
||||||
80%,
|
80%,
|
||||||
100% {
|
100% {
|
||||||
transform: scale(0.7);
|
transform: scale(0.7);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
40% {
|
40% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,374 +1,374 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="welcome-screen">
|
<div class="welcome-screen">
|
||||||
<!-- Logo 和标题 -->
|
<!-- Logo 和标题 -->
|
||||||
<div class="welcome-header">
|
<div class="welcome-header">
|
||||||
<div class="logo-wrapper">
|
<div class="logo-wrapper">
|
||||||
<div class="logo-icon">
|
<div class="logo-icon">
|
||||||
<Bot :size="40" />
|
<Bot :size="40" />
|
||||||
</div>
|
</div>
|
||||||
<div class="logo-glow"></div>
|
<div class="logo-glow"></div>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="title">AI 智能助手</h1>
|
<h1 class="title">AI 智能助手</h1>
|
||||||
<p class="subtitle">我可以帮助你解答问题、生成内容、分析数据等</p>
|
<p class="subtitle">我可以帮助你解答问题、生成内容、分析数据等</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 功能卡片 -->
|
<!-- 功能卡片 -->
|
||||||
<div class="feature-cards">
|
<div class="feature-cards">
|
||||||
<div
|
<div
|
||||||
v-for="feature in features"
|
v-for="feature in features"
|
||||||
:key="feature.title"
|
:key="feature.title"
|
||||||
class="feature-card"
|
class="feature-card"
|
||||||
>
|
>
|
||||||
<div class="feature-icon" :style="{ background: feature.gradient }">
|
<div class="feature-icon" :style="{ background: feature.gradient }">
|
||||||
<component :is="feature.icon" :size="22" />
|
<component :is="feature.icon" :size="22" />
|
||||||
</div>
|
</div>
|
||||||
<h3>{{ feature.title }}</h3>
|
<h3>{{ feature.title }}</h3>
|
||||||
<p>{{ feature.description }}</p>
|
<p>{{ feature.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 快速开始建议 -->
|
<!-- 快速开始建议 -->
|
||||||
<div class="quick-start">
|
<div class="quick-start">
|
||||||
<h4>试试这些问题</h4>
|
<h4>试试这些问题</h4>
|
||||||
<div class="suggestions-grid">
|
<div class="suggestions-grid">
|
||||||
<button
|
<button
|
||||||
v-for="suggestion in suggestions"
|
v-for="suggestion in suggestions"
|
||||||
:key="suggestion.text"
|
:key="suggestion.text"
|
||||||
class="suggestion-card"
|
class="suggestion-card"
|
||||||
@click="$emit('select', suggestion.text)"
|
@click="$emit('select', suggestion.text)"
|
||||||
>
|
>
|
||||||
<component :is="suggestion.icon" :size="18" class="suggestion-icon" />
|
<component :is="suggestion.icon" :size="18" class="suggestion-icon" />
|
||||||
<span>{{ suggestion.text }}</span>
|
<span>{{ suggestion.text }}</span>
|
||||||
<ChevronRight :size="16" class="arrow-icon" />
|
<ChevronRight :size="16" class="arrow-icon" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部提示 -->
|
<!-- 底部提示 -->
|
||||||
<div class="welcome-footer">
|
<div class="welcome-footer">
|
||||||
<div class="tip">
|
<div class="tip">
|
||||||
<Keyboard :size="14" />
|
<Keyboard :size="14" />
|
||||||
<span>按 <kbd>Ctrl</kbd> + <kbd>/</kbd> 聚焦输入框</span>
|
<span>按 <kbd>Ctrl</kbd> + <kbd>/</kbd> 聚焦输入框</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tip">
|
<div class="tip">
|
||||||
<Zap :size="14" />
|
<Zap :size="14" />
|
||||||
<span>支持 Markdown、代码高亮、LaTeX 公式</span>
|
<span>支持 Markdown、代码高亮、LaTeX 公式</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Code,
|
Code,
|
||||||
Image,
|
Image,
|
||||||
FileText,
|
FileText,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Zap,
|
Zap,
|
||||||
Globe,
|
Globe,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
PenTool,
|
PenTool,
|
||||||
} from '@/components/icons'
|
} from '@/components/icons'
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
select: [text: string]
|
select: [text: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const features = computed(() => [
|
const features = computed(() => [
|
||||||
{
|
{
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
title: '智能对话',
|
title: '智能对话',
|
||||||
description: '自然流畅的对话体验,理解上下文',
|
description: '自然流畅的对话体验,理解上下文',
|
||||||
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Code,
|
icon: Code,
|
||||||
title: '代码助手',
|
title: '代码助手',
|
||||||
description: '编写、解释、优化各种编程语言代码',
|
description: '编写、解释、优化各种编程语言代码',
|
||||||
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Image,
|
icon: Image,
|
||||||
title: '图像理解',
|
title: '图像理解',
|
||||||
description: '分析图片内容,提取关键信息',
|
description: '分析图片内容,提取关键信息',
|
||||||
gradient: 'linear-gradient(135deg, #ec4899 0%, #d946ef 100%)',
|
gradient: 'linear-gradient(135deg, #ec4899 0%, #d946ef 100%)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
title: '文档处理',
|
title: '文档处理',
|
||||||
description: '阅读、总结、翻译各类文档',
|
description: '阅读、总结、翻译各类文档',
|
||||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)',
|
gradient: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const suggestions = computed(() => [
|
const suggestions = computed(() => [
|
||||||
{
|
{
|
||||||
icon: Lightbulb,
|
icon: Lightbulb,
|
||||||
text: '帮我写一个 Vue 3 组件示例',
|
text: '帮我写一个 Vue 3 组件示例',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
text: '解释一下什么是机器学习',
|
text: '解释一下什么是机器学习',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: PenTool,
|
icon: PenTool,
|
||||||
text: '帮我写一封商务邮件',
|
text: '帮我写一封商务邮件',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Code,
|
icon: Code,
|
||||||
text: '如何优化 React 应用性能',
|
text: '如何优化 React 应用性能',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.welcome-screen {
|
.welcome-screen {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 40px 24px;
|
padding: 40px 24px;
|
||||||
animation: fadeIn 0.5s ease;
|
animation: fadeIn 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-header {
|
.welcome-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 48px;
|
margin-bottom: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-wrapper {
|
.logo-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon {
|
.logo-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 20px 40px -12px rgba(59, 130, 246, 0.35);
|
box-shadow: 0 20px 40px -12px rgba(59, 130, 246, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-glow {
|
.logo-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -20px;
|
inset: -20px;
|
||||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.2) 0%, transparent 70%);
|
background: radial-gradient(circle, rgba(59, 130, 246, 0.2) 0%, transparent 70%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-cards {
|
.feature-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 48px;
|
margin-bottom: 48px;
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
@media (max-width: 500px) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card {
|
.feature-card {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #1e1e2e;
|
background: #1e1e2e;
|
||||||
border-color: #2d2d3d;
|
border-color: #2d2d3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-icon {
|
.feature-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
color: white;
|
color: white;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-start {
|
.quick-start {
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestions-grid {
|
.suggestions-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-card {
|
.suggestion-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #1e1e2e;
|
background: #1e1e2e;
|
||||||
border-color: #2d2d3d;
|
border-color: #2d2d3d;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
background: rgba(59, 130, 246, 0.05);
|
background: rgba(59, 130, 246, 0.05);
|
||||||
|
|
||||||
.arrow-icon {
|
.arrow-icon {
|
||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-icon {
|
.suggestion-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow-icon {
|
.arrow-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-footer {
|
.welcome-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tip {
|
.tip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
|
||||||
kbd {
|
kbd {
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #374151;
|
background: #374151;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translateY(20px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,133 +1,133 @@
|
||||||
export {
|
export {
|
||||||
// 通用图标
|
// 通用图标
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
Check,
|
Check,
|
||||||
Plus,
|
Plus,
|
||||||
Minus,
|
Minus,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Info,
|
Info,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
|
|
||||||
// 主题图标
|
// 主题图标
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
Monitor,
|
Monitor,
|
||||||
|
|
||||||
// 用户/角色
|
// 用户/角色
|
||||||
User,
|
User,
|
||||||
Bot,
|
Bot,
|
||||||
Users,
|
Users,
|
||||||
|
|
||||||
// 消息/对话
|
// 消息/对话
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
Send,
|
Send,
|
||||||
SendHorizontal,
|
SendHorizontal,
|
||||||
|
|
||||||
// 操作图标
|
// 操作图标
|
||||||
Copy,
|
Copy,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Edit3,
|
Edit3,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
Download,
|
Download,
|
||||||
Upload,
|
Upload,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Link,
|
Link,
|
||||||
Share2,
|
Share2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Brain,
|
Brain,
|
||||||
|
|
||||||
// 反馈图标
|
// 反馈图标
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
Heart,
|
Heart,
|
||||||
Star,
|
Star,
|
||||||
|
|
||||||
// 导航图标
|
// 导航图标
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
|
||||||
// 状态/标记
|
// 状态/标记
|
||||||
Pin,
|
Pin,
|
||||||
PinOff,
|
PinOff,
|
||||||
Archive,
|
Archive,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Flag,
|
Flag,
|
||||||
Clock,
|
Clock,
|
||||||
Calendar,
|
Calendar,
|
||||||
History,
|
History,
|
||||||
|
|
||||||
// 文件夹/文件
|
// 文件夹/文件
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
File,
|
File,
|
||||||
FileText,
|
FileText,
|
||||||
FileCode,
|
FileCode,
|
||||||
FileImage,
|
FileImage,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
|
|
||||||
// 媒体图标
|
// 媒体图标
|
||||||
Image,
|
Image,
|
||||||
Video,
|
Video,
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
Square,
|
Square,
|
||||||
StopCircle,
|
StopCircle,
|
||||||
Mic,
|
Mic,
|
||||||
MicOff,
|
MicOff,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
Camera,
|
Camera,
|
||||||
|
|
||||||
// 功能图标
|
// 功能图标
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Wand2,
|
Wand2,
|
||||||
Zap,
|
Zap,
|
||||||
Globe,
|
Globe,
|
||||||
Wifi,
|
Wifi,
|
||||||
Code,
|
Code,
|
||||||
Terminal,
|
Terminal,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Command,
|
Command,
|
||||||
Hash,
|
Hash,
|
||||||
AtSign,
|
AtSign,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
PenTool,
|
PenTool,
|
||||||
Palette,
|
Palette,
|
||||||
|
|
||||||
// 布局图标
|
// 布局图标
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Expand,
|
Expand,
|
||||||
Shrink,
|
Shrink,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
|
|
||||||
// 其他
|
// 其他
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Lock,
|
Lock,
|
||||||
Unlock,
|
Unlock,
|
||||||
Shield,
|
Shield,
|
||||||
Bell,
|
Bell,
|
||||||
BellOff,
|
BellOff,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
|
|
|
||||||
|
|
@ -1,266 +1,266 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="attachment-preview">
|
<div class="attachment-preview">
|
||||||
<TransitionGroup name="attachment">
|
<TransitionGroup name="attachment">
|
||||||
<div
|
<div
|
||||||
v-for="attachment in attachments"
|
v-for="attachment in attachments"
|
||||||
:key="attachment.id"
|
:key="attachment.id"
|
||||||
class="attachment-item"
|
class="attachment-item"
|
||||||
:class="attachment.type"
|
:class="attachment.type"
|
||||||
>
|
>
|
||||||
<!-- 图片预览 -->
|
<!-- 图片预览 -->
|
||||||
<template v-if="attachment.type === 'image'">
|
<template v-if="attachment.type === 'image'">
|
||||||
<img :src="attachment.url" :alt="attachment.name" class="preview-image" />
|
<img :src="attachment.url" :alt="attachment.name" class="preview-image" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 视频预览 -->
|
<!-- 视频预览 -->
|
||||||
<template v-else-if="attachment.type === 'video'">
|
<template v-else-if="attachment.type === 'video'">
|
||||||
<div class="preview-video">
|
<div class="preview-video">
|
||||||
<img
|
<img
|
||||||
v-if="attachment.thumbnail"
|
v-if="attachment.thumbnail"
|
||||||
:src="attachment.thumbnail"
|
:src="attachment.thumbnail"
|
||||||
:alt="attachment.name"
|
:alt="attachment.name"
|
||||||
/>
|
/>
|
||||||
<div v-else class="video-placeholder">
|
<div v-else class="video-placeholder">
|
||||||
<Video :size="24" />
|
<Video :size="24" />
|
||||||
</div>
|
</div>
|
||||||
<div class="video-badge">
|
<div class="video-badge">
|
||||||
<Play :size="12" />
|
<Play :size="12" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 文件预览 -->
|
<!-- 文件预览 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="preview-file">
|
<div class="preview-file">
|
||||||
<span class="file-emoji">{{ getFileEmoji(attachment.mimeType) }}</span>
|
<span class="file-emoji">{{ getFileEmoji(attachment.mimeType) }}</span>
|
||||||
<div class="file-details">
|
<div class="file-details">
|
||||||
<span class="file-name">{{ truncateName(attachment.name) }}</span>
|
<span class="file-name">{{ truncateName(attachment.name) }}</span>
|
||||||
<span class="file-size">{{ formatSize(attachment.size) }}</span>
|
<span class="file-size">{{ formatSize(attachment.size) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 删除按钮 -->
|
<!-- 删除按钮 -->
|
||||||
<button
|
<button
|
||||||
class="remove-btn"
|
class="remove-btn"
|
||||||
@click="$emit('remove', attachment.id)"
|
@click="$emit('remove', attachment.id)"
|
||||||
>
|
>
|
||||||
<X :size="14" />
|
<X :size="14" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 上传进度 -->
|
<!-- 上传进度 -->
|
||||||
<div v-if="attachment.uploading" class="upload-progress">
|
<div v-if="attachment.uploading" class="upload-progress">
|
||||||
<div
|
<div
|
||||||
class="progress-bar"
|
class="progress-bar"
|
||||||
:style="{ width: `${attachment.progress || 0}%` }"
|
:style="{ width: `${attachment.progress || 0}%` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { X, Video, Play } from '@/components/icons'
|
import { X, Video, Play } from '@/components/icons'
|
||||||
import { formatFileSize, getFileIcon, truncateText } from '@/utils/helpers'
|
import { formatFileSize, getFileIcon, truncateText } from '@/utils/helpers'
|
||||||
|
|
||||||
interface AttachmentWithProgress {
|
interface AttachmentWithProgress {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'image' | 'file' | 'video'
|
type: 'image' | 'file' | 'video'
|
||||||
url: string
|
url: string
|
||||||
size?: number
|
size?: number
|
||||||
mimeType?: string
|
mimeType?: string
|
||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
uploading?: boolean
|
uploading?: boolean
|
||||||
progress?: number
|
progress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
attachments: AttachmentWithProgress[]
|
attachments: AttachmentWithProgress[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
remove: [id: string]
|
remove: [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function getFileEmoji(mimeType?: string) {
|
function getFileEmoji(mimeType?: string) {
|
||||||
return getFileIcon(mimeType || '')
|
return getFileIcon(mimeType || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(size?: number) {
|
function formatSize(size?: number) {
|
||||||
return size ? formatFileSize(size) : ''
|
return size ? formatFileSize(size) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateName(name: string) {
|
function truncateName(name: string) {
|
||||||
return truncateText(name, 20)
|
return truncateText(name, 20)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.attachment-preview {
|
.attachment-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
border-bottom-color: #374151;
|
border-bottom-color: #374151;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-item {
|
.attachment-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #374151;
|
background: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.image,
|
&.image,
|
||||||
&.video {
|
&.video {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.file {
|
&.file {
|
||||||
padding: 10px 40px 10px 12px;
|
padding: 10px 40px 10px 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-image {
|
.preview-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-video {
|
.preview-video {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-placeholder {
|
.video-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #4b5563;
|
background: #4b5563;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-badge {
|
.video-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 6px;
|
bottom: 6px;
|
||||||
left: 6px;
|
left: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-file {
|
.preview-file {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-emoji {
|
.file-emoji {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-details {
|
.file-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name {
|
.file-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-size {
|
.file-size {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-btn {
|
.remove-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
right: 4px;
|
right: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
color: white;
|
color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
.attachment-item:hover & {
|
.attachment-item:hover & {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(239, 68, 68, 0.9);
|
background: rgba(239, 68, 68, 0.9);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-progress {
|
.upload-progress {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #3b82f6;
|
background: #3b82f6;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过渡动画
|
// 过渡动画
|
||||||
.attachment-enter-active,
|
.attachment-enter-active,
|
||||||
.attachment-leave-active {
|
.attachment-leave-active {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-enter-from {
|
.attachment-enter-from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.8);
|
transform: scale(0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-leave-to {
|
.attachment-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.8);
|
transform: scale(0.8);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,212 +1,212 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="code-block" :class="{ 'is-expanded': isExpanded }">
|
<div class="code-block" :class="{ 'is-expanded': isExpanded }">
|
||||||
<!-- 代码块头部 -->
|
<!-- 代码块头部 -->
|
||||||
<div class="code-header">
|
<div class="code-header">
|
||||||
<div class="code-language">
|
<div class="code-language">
|
||||||
<Code :size="14" />
|
<Code :size="14" />
|
||||||
<span>{{ language || 'code' }}</span>
|
<span>{{ language || 'code' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="code-actions">
|
<div class="code-actions">
|
||||||
<button
|
<button
|
||||||
v-if="canExpand"
|
v-if="canExpand"
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
:title="isExpanded ? '收起' : '展开'"
|
:title="isExpanded ? '收起' : '展开'"
|
||||||
@click="toggleExpand"
|
@click="toggleExpand"
|
||||||
>
|
>
|
||||||
<Maximize2 v-if="!isExpanded" :size="14" />
|
<Maximize2 v-if="!isExpanded" :size="14" />
|
||||||
<Minimize2 v-else :size="14" />
|
<Minimize2 v-else :size="14" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
:class="{ copied: isCopied }"
|
:class="{ copied: isCopied }"
|
||||||
title="复制代码"
|
title="复制代码"
|
||||||
@click="handleCopy"
|
@click="handleCopy"
|
||||||
>
|
>
|
||||||
<Check v-if="isCopied" :size="14" />
|
<Check v-if="isCopied" :size="14" />
|
||||||
<Copy v-else :size="14" />
|
<Copy v-else :size="14" />
|
||||||
<span v-if="isCopied">已复制</span>
|
<span v-if="isCopied">已复制</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 代码内容 -->
|
<!-- 代码内容 -->
|
||||||
<div class="code-content">
|
<div class="code-content">
|
||||||
<pre><code :class="`language-${language}`">{{ code }}</code></pre>
|
<pre><code :class="`language-${language}`">{{ code }}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 行号(可选) -->
|
<!-- 行号(可选) -->
|
||||||
<div v-if="showLineNumbers" class="line-numbers">
|
<div v-if="showLineNumbers" class="line-numbers">
|
||||||
<span v-for="n in lineCount" :key="n">{{ n }}</span>
|
<span v-for="n in lineCount" :key="n">{{ n }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { Code, Copy, Check, Maximize2, Minimize2 } from '@/components/icons'
|
import { Code, Copy, Check, Maximize2, Minimize2 } from '@/components/icons'
|
||||||
import { copyToClipboard } from '@/utils/helpers'
|
import { copyToClipboard } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
code: string
|
code: string
|
||||||
language?: string
|
language?: string
|
||||||
showLineNumbers?: boolean
|
showLineNumbers?: boolean
|
||||||
maxHeight?: number
|
maxHeight?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
language: 'plaintext',
|
language: 'plaintext',
|
||||||
showLineNumbers: true,
|
showLineNumbers: true,
|
||||||
maxHeight: 400,
|
maxHeight: 400,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
copy: []
|
copy: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isCopied = ref(false)
|
const isCopied = ref(false)
|
||||||
const isExpanded = ref(false)
|
const isExpanded = ref(false)
|
||||||
|
|
||||||
const lineCount = computed(() => {
|
const lineCount = computed(() => {
|
||||||
return props.code.split('\n').length
|
return props.code.split('\n').length
|
||||||
})
|
})
|
||||||
|
|
||||||
const canExpand = computed(() => {
|
const canExpand = computed(() => {
|
||||||
return lineCount.value > 15
|
return lineCount.value > 15
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
const success = await copyToClipboard(props.code)
|
const success = await copyToClipboard(props.code)
|
||||||
if (success) {
|
if (success) {
|
||||||
isCopied.value = true
|
isCopied.value = true
|
||||||
emit('copy')
|
emit('copy')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isCopied.value = false
|
isCopied.value = false
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpand() {
|
function toggleExpand() {
|
||||||
isExpanded.value = !isExpanded.value
|
isExpanded.value = !isExpanded.value
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.code-block {
|
.code-block {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #1e1e2e;
|
background: #1e1e2e;
|
||||||
border: 1px solid #2d2d3d;
|
border: 1px solid #2d2d3d;
|
||||||
|
|
||||||
&.is-expanded {
|
&.is-expanded {
|
||||||
.code-content {
|
.code-content {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-header {
|
.code-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
background: #181825;
|
background: #181825;
|
||||||
border-bottom: 1px solid #2d2d3d;
|
border-bottom: 1px solid #2d2d3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-language {
|
.code-language {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #a6adc8;
|
color: #a6adc8;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-actions {
|
.code-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: #a6adc8;
|
color: #a6adc8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #cdd6f4;
|
color: #cdd6f4;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.copied {
|
&.copied {
|
||||||
background: rgba(166, 227, 161, 0.2);
|
background: rgba(166, 227, 161, 0.2);
|
||||||
color: #a6e3a1;
|
color: #a6e3a1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-content {
|
.code-content {
|
||||||
max-height: v-bind('maxHeight + "px"');
|
max-height: v-bind('maxHeight + "px"');
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #cdd6f4;
|
color: #cdd6f4;
|
||||||
tab-size: 2;
|
tab-size: 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-numbers {
|
.line-numbers {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 49px;
|
top: 49px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border-right: 1px solid #2d2d3d;
|
border-right: 1px solid #2d2d3d;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #585b70;
|
color: #585b70;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.code-content) {
|
:deep(.code-content) {
|
||||||
.keyword { color: #cba6f7; }
|
.keyword { color: #cba6f7; }
|
||||||
.string { color: #a6e3a1; }
|
.string { color: #a6e3a1; }
|
||||||
.number { color: #fab387; }
|
.number { color: #fab387; }
|
||||||
.comment { color: #6c7086; font-style: italic; }
|
.comment { color: #6c7086; font-style: italic; }
|
||||||
.function { color: #89b4fa; }
|
.function { color: #89b4fa; }
|
||||||
.operator { color: #89dceb; }
|
.operator { color: #89dceb; }
|
||||||
.punctuation { color: #9399b2; }
|
.punctuation { color: #9399b2; }
|
||||||
.class-name { color: #f9e2af; }
|
.class-name { color: #f9e2af; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,320 +1,320 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="message-actions" :class="{ visible: alwaysVisible || isHovered }">
|
<div class="message-actions" :class="{ visible: alwaysVisible || isHovered }">
|
||||||
<!-- 复制按钮 -->
|
<!-- 复制按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="!isBreak"
|
v-if="!isBreak"
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
:class="{ success: copied }"
|
:class="{ success: copied }"
|
||||||
title="复制内容"
|
title="复制内容"
|
||||||
@click="handleCopy"
|
@click="handleCopy"
|
||||||
>
|
>
|
||||||
<Check v-if="copied" :size="15" />
|
<Check v-if="copied" :size="15" />
|
||||||
<Copy v-else :size="15" />
|
<Copy v-else :size="15" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 点赞按钮 -->
|
<!-- 点赞按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="isNew && !isBreak"
|
v-if="isNew && !isBreak"
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
:class="{ active: feedback?.liked }"
|
:class="{ active: feedback?.liked }"
|
||||||
title="有帮助"
|
title="有帮助"
|
||||||
@click="handleLike"
|
@click="handleLike"
|
||||||
>
|
>
|
||||||
<ThumbsUp :size="15" />
|
<ThumbsUp :size="15" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 点踩按钮 -->
|
<!-- 点踩按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="isNew && !isBreak"
|
v-if="isNew && !isBreak"
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
:class="{ active: feedback?.disliked }"
|
:class="{ active: feedback?.disliked }"
|
||||||
title="没帮助"
|
title="没帮助"
|
||||||
@click="handleDislike"
|
@click="handleDislike"
|
||||||
>
|
>
|
||||||
<ThumbsDown :size="15" />
|
<ThumbsDown :size="15" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 重新生成(仅AI消息) -->
|
<!-- 重新生成(仅AI消息) -->
|
||||||
<button
|
<button
|
||||||
v-if="(showRegenerate && isNew) || isBreak"
|
v-if="(showRegenerate && isNew) || isBreak"
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
title="重新生成"
|
title="重新生成"
|
||||||
@click="handleRegenerate"
|
@click="handleRegenerate"
|
||||||
>
|
>
|
||||||
<RefreshCw :size="15" />
|
<RefreshCw :size="15" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 更多操作 -->
|
<!-- 更多操作 -->
|
||||||
<div class="more-menu" v-if="showMore">
|
<div class="more-menu" v-if="showMore">
|
||||||
<button class="action-btn" title="更多" @click="toggleMoreMenu">
|
<button class="action-btn" title="更多" @click="toggleMoreMenu">
|
||||||
<MoreHorizontal :size="15" />
|
<MoreHorizontal :size="15" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Transition name="dropdown">
|
<Transition name="dropdown">
|
||||||
<div v-if="showMoreMenu" class="dropdown-menu">
|
<div v-if="showMoreMenu" class="dropdown-menu">
|
||||||
<button
|
<button
|
||||||
v-if="isNew && !isBreak"
|
v-if="isNew && !isBreak"
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
@click="handleEdit"
|
@click="handleEdit"
|
||||||
>
|
>
|
||||||
<Edit3 :size="14" />
|
<Edit3 :size="14" />
|
||||||
<span>编辑</span>
|
<span>编辑</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="!isBreak" class="dropdown-item" @click="handleShare">
|
<button v-if="!isBreak" class="dropdown-item" @click="handleShare">
|
||||||
<ExternalLink :size="14" />
|
<ExternalLink :size="14" />
|
||||||
<span>分享</span>
|
<span>分享</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item danger" @click="handleDelete">
|
<button class="dropdown-item danger" @click="handleDelete">
|
||||||
<Trash2 :size="14" />
|
<Trash2 :size="14" />
|
||||||
<span>删除</span>
|
<span>删除</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Edit3,
|
Edit3,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import { copyToClipboard } from "@/utils/helpers";
|
import { copyToClipboard } from "@/utils/helpers";
|
||||||
import type { MessageFeedback } from "@/types/chat";
|
import type { MessageFeedback } from "@/types/chat";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
content: string;
|
content: string;
|
||||||
feedback?: MessageFeedback;
|
feedback?: MessageFeedback;
|
||||||
showRegenerate?: boolean;
|
showRegenerate?: boolean;
|
||||||
showMore?: boolean;
|
showMore?: boolean;
|
||||||
alwaysVisible?: boolean;
|
alwaysVisible?: boolean;
|
||||||
isHovered?: boolean;
|
isHovered?: boolean;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
isBreak?: boolean;
|
isBreak?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
showRegenerate: false,
|
showRegenerate: false,
|
||||||
showMore: true,
|
showMore: true,
|
||||||
alwaysVisible: false,
|
alwaysVisible: false,
|
||||||
isHovered: false,
|
isHovered: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
copy: [];
|
copy: [];
|
||||||
like: [];
|
like: [];
|
||||||
dislike: [];
|
dislike: [];
|
||||||
regenerate: [];
|
regenerate: [];
|
||||||
edit: [];
|
edit: [];
|
||||||
share: [];
|
share: [];
|
||||||
delete: [];
|
delete: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const copied = ref(false);
|
const copied = ref(false);
|
||||||
const showMoreMenu = ref(false);
|
const showMoreMenu = ref(false);
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
const success = await copyToClipboard(props.content);
|
const success = await copyToClipboard(props.content);
|
||||||
if (success) {
|
if (success) {
|
||||||
copied.value = true;
|
copied.value = true;
|
||||||
emit("copy");
|
emit("copy");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copied.value = false;
|
copied.value = false;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLike() {
|
function handleLike() {
|
||||||
emit("like");
|
emit("like");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDislike() {
|
function handleDislike() {
|
||||||
emit("dislike");
|
emit("dislike");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRegenerate() {
|
function handleRegenerate() {
|
||||||
emit("regenerate");
|
emit("regenerate");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMoreMenu() {
|
function toggleMoreMenu() {
|
||||||
showMoreMenu.value = !showMoreMenu.value;
|
showMoreMenu.value = !showMoreMenu.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit() {
|
function handleEdit() {
|
||||||
showMoreMenu.value = false;
|
showMoreMenu.value = false;
|
||||||
emit("edit");
|
emit("edit");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShare() {
|
function handleShare() {
|
||||||
showMoreMenu.value = false;
|
showMoreMenu.value = false;
|
||||||
emit("share");
|
emit("share");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
showMoreMenu.value = false;
|
showMoreMenu.value = false;
|
||||||
emit("delete");
|
emit("delete");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击外部关闭菜单
|
// 点击外部关闭菜单
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (!target.closest(".more-menu")) {
|
if (!target.closest(".more-menu")) {
|
||||||
showMoreMenu.value = false;
|
showMoreMenu.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 挂载时添加事件监听
|
// 挂载时添加事件监听
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
document.addEventListener("click", handleClickOutside);
|
document.addEventListener("click", handleClickOutside);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.message-actions {
|
.message-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: white;
|
background: white;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(4px);
|
transform: translateY(4px);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #2d2d3d;
|
background: #2d2d3d;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.visible {
|
&.visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #374151;
|
background: #374151;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: rgba(59, 130, 246, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.success {
|
&.success {
|
||||||
color: #10b981;
|
color: #10b981;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(16, 185, 129, 0.1);
|
background: rgba(16, 185, 129, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-menu {
|
.more-menu {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #2d2d3d;
|
background: #2d2d3d;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #374151;
|
background: #374151;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.danger {
|
&.danger {
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下拉动画
|
// 下拉动画
|
||||||
.dropdown-enter-active,
|
.dropdown-enter-active,
|
||||||
.dropdown-leave-active {
|
.dropdown-leave-active {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-enter-from,
|
.dropdown-enter-from,
|
||||||
.dropdown-leave-to {
|
.dropdown-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(8px) scale(0.95);
|
transform: translateY(8px) scale(0.95);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,90 +1,90 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import * as echarts from "echarts";
|
import * as echarts from "echarts";
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import Loading from "./Loading.vue";
|
import Loading from "./Loading.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: {
|
node: {
|
||||||
type: "vmr_container";
|
type: "vmr_container";
|
||||||
name: string;
|
name: string;
|
||||||
children?: Array<{ type: string; raw: string }>;
|
children?: Array<{ type: string; raw: string }>;
|
||||||
};
|
};
|
||||||
isDark?: boolean;
|
isDark?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
// 只处理 echarts 容器
|
// 只处理 echarts 容器
|
||||||
const isEChartsContainer = computed(() => props.node.name === "echarts");
|
const isEChartsContainer = computed(() => props.node.name === "echarts");
|
||||||
|
|
||||||
const chartRef = ref<HTMLElement>();
|
const chartRef = ref<HTMLElement>();
|
||||||
let chartInstance: echarts.ECharts | null = null;
|
let chartInstance: echarts.ECharts | null = null;
|
||||||
|
|
||||||
// 从子节点提取 JSON
|
// 从子节点提取 JSON
|
||||||
const chartOption = computed(() => {
|
const chartOption = computed(() => {
|
||||||
if (!props.node.children || props.node.children.length === 0) {
|
if (!props.node.children || props.node.children.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const code = props.node.children[0].raw;
|
const code = props.node.children[0].raw;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(code);
|
return JSON.parse(code);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function initChart() {
|
function initChart() {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
if (!isEChartsContainer.value || !chartRef.value || !chartOption.value)
|
if (!isEChartsContainer.value || !chartRef.value || !chartOption.value)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (chartInstance) {
|
if (chartInstance) {
|
||||||
chartInstance.dispose();
|
chartInstance.dispose();
|
||||||
}
|
}
|
||||||
const theme = props.isDark ? "dark" : undefined;
|
const theme = props.isDark ? "dark" : undefined;
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
chartInstance = echarts.init(chartRef.value, theme);
|
chartInstance = echarts.init(chartRef.value, theme);
|
||||||
chartInstance.setOption(chartOption.value, true);
|
chartInstance.setOption(chartOption.value, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.isDark, initChart);
|
watch(() => props.isDark, initChart);
|
||||||
watch(chartOption, (option) => {
|
watch(chartOption, (option) => {
|
||||||
if (chartInstance && option) {
|
if (chartInstance && option) {
|
||||||
chartInstance.setOption(option, true);
|
chartInstance.setOption(option, true);
|
||||||
} else if (option) {
|
} else if (option) {
|
||||||
initChart();
|
initChart();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(initChart);
|
onMounted(initChart);
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
chartInstance?.dispose();
|
chartInstance?.dispose();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="isEChartsContainer" class="vmr-container vmr-container-echarts">
|
<div v-if="isEChartsContainer" class="vmr-container vmr-container-echarts">
|
||||||
<div ref="chartRef" style="width: 100%; height: 400px" />
|
<div ref="chartRef" style="width: 100%; height: 400px" />
|
||||||
<Loading :loading="isLoading" text="正在渲染数据..." />
|
<Loading :loading="isLoading" text="正在渲染数据..." />
|
||||||
<slot v-if="!chartOption" />
|
<slot v-if="!chartOption" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="vmr-container" :class="`vmr-container-${node.name}`">
|
<div v-else class="vmr-container" :class="`vmr-container-${node.name}`">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.vmr-container-echarts {
|
.vmr-container-echarts {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .vmr-container-echarts {
|
.dark .vmr-container-echarts {
|
||||||
border-color: #374151;
|
border-color: #374151;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,70 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="loading" class="loading-overlay">
|
<div v-if="loading" class="loading-overlay">
|
||||||
<div class="loading-spinner">
|
<div class="loading-spinner">
|
||||||
<div class="spinner-box">
|
<div class="spinner-box">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="text" class="loading-text">{{ text }}</p>
|
<p v-if="text" class="loading-text">{{ text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { watch } from "vue";
|
import { watch } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "加载中...",
|
default: "加载中...",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.loading-overlay {
|
.loading-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: rgba(255, 255, 255, 0.8);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
.spinner-box {
|
.spinner-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
border: 4px solid #f3f3f3;
|
border: 4px solid #f3f3f3;
|
||||||
border-top: 4px solid #3498db;
|
border-top: 4px solid #3498db;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-text {
|
.loading-text {
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,182 +1,182 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { MarkdownRender } from "markstream-vue";
|
import { MarkdownRender } from "markstream-vue";
|
||||||
import { useClipboard } from "@vueuse/core";
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
node: {
|
node: {
|
||||||
type: "think";
|
type: "think";
|
||||||
content: string;
|
content: string;
|
||||||
children: any[];
|
children: any[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { copy } = useClipboard({ legacy: true });
|
const { copy } = useClipboard({ legacy: true });
|
||||||
|
|
||||||
async function textCopy(data: any) {
|
async function textCopy(data: any) {
|
||||||
if (typeof data === "string") {
|
if (typeof data === "string") {
|
||||||
copy(data);
|
copy(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400 flex items-start gap-3"
|
class="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400 flex items-start gap-3"
|
||||||
>
|
>
|
||||||
<div class="flex-shrink-0 mt-1">
|
<div class="flex-shrink-0 mt-1">
|
||||||
<!-- decorative thinking SVG icon -->
|
<!-- decorative thinking SVG icon -->
|
||||||
<div
|
<div
|
||||||
class="w-9 h-9 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100"
|
class="w-9 h-9 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5"
|
class="w-5 h-5"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z"
|
d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="0.8"
|
stroke-width="0.8"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
opacity="0.9"
|
opacity="0.9"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-baseline gap-3">
|
<div class="flex items-baseline gap-3">
|
||||||
<strong class="text-sm">Thinking</strong>
|
<strong class="text-sm">Thinking</strong>
|
||||||
<span class="text-xs text-slate-500 dark:text-slate-300"
|
<span class="text-xs text-slate-500 dark:text-slate-300"
|
||||||
>(assistant)</span
|
>(assistant)</span
|
||||||
>
|
>
|
||||||
<!-- keep dots in DOM to avoid layout shift; toggle visibility with classes -->
|
<!-- keep dots in DOM to avoid layout shift; toggle visibility with classes -->
|
||||||
<span class="ml-2" aria-hidden="true">
|
<span class="ml-2" aria-hidden="true">
|
||||||
<span
|
<span
|
||||||
class="thinking-dots"
|
class="thinking-dots"
|
||||||
:class="[node.loading ? 'visible' : 'hidden']"
|
:class="[node.loading ? 'visible' : 'hidden']"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<span class="dot dot-1" />
|
<span class="dot dot-1" />
|
||||||
<span class="dot dot-2" />
|
<span class="dot dot-2" />
|
||||||
<span class="dot dot-3" />
|
<span class="dot dot-3" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mt-1 text-sm leading-relaxed text-slate-800 dark:text-slate-100"
|
class="mt-1 text-sm leading-relaxed text-slate-800 dark:text-slate-100"
|
||||||
>
|
>
|
||||||
<!-- sr-only live region only present when loading to announce change -->
|
<!-- sr-only live region only present when loading to announce change -->
|
||||||
<span v-if="node.loading" class="sr-only" aria-live="polite"
|
<span v-if="node.loading" class="sr-only" aria-live="polite"
|
||||||
>Thinking…</span
|
>Thinking…</span
|
||||||
>
|
>
|
||||||
<transition name="fade" mode="out-in">
|
<transition name="fade" mode="out-in">
|
||||||
<div
|
<div
|
||||||
key="{{ node.loading ? 'loading' : 'ready' }}"
|
key="{{ node.loading ? 'loading' : 'ready' }}"
|
||||||
class="content-area"
|
class="content-area"
|
||||||
>
|
>
|
||||||
<MarkdownRender :content="node.content" @copy="textCopy" />
|
<MarkdownRender :content="node.content" @copy="textCopy" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.thinking-node {
|
.thinking-node {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
.dark .thinking-node {
|
.dark .thinking-node {
|
||||||
color: #e6f0ff;
|
color: #e6f0ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animated dots for thinking state */
|
/* Animated dots for thinking state */
|
||||||
.thinking-dots {
|
.thinking-dots {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
height: 12px; /* reserve vertical space so toggling doesn't collapse layout */
|
height: 12px; /* reserve vertical space so toggling doesn't collapse layout */
|
||||||
transition:
|
transition:
|
||||||
opacity 160ms linear,
|
opacity 160ms linear,
|
||||||
transform 160ms linear;
|
transform 160ms linear;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
.thinking-dots .dot {
|
.thinking-dots .dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: #1e3a8a; /* blue-800 */
|
background: #1e3a8a; /* blue-800 */
|
||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
.thinking-dots.visible {
|
.thinking-dots.visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.thinking-dots.hidden {
|
.thinking-dots.hidden {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
.thinking-dots.visible .dot-1 {
|
.thinking-dots.visible .dot-1 {
|
||||||
animation: think-bounce 1s infinite ease-in-out;
|
animation: think-bounce 1s infinite ease-in-out;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
}
|
}
|
||||||
.thinking-dots.visible .dot-2 {
|
.thinking-dots.visible .dot-2 {
|
||||||
animation: think-bounce 1s infinite ease-in-out;
|
animation: think-bounce 1s infinite ease-in-out;
|
||||||
animation-delay: 0.12s;
|
animation-delay: 0.12s;
|
||||||
}
|
}
|
||||||
.thinking-dots.visible .dot-3 {
|
.thinking-dots.visible .dot-3 {
|
||||||
animation: think-bounce 1s infinite ease-in-out;
|
animation: think-bounce 1s infinite ease-in-out;
|
||||||
animation-delay: 0.24s;
|
animation-delay: 0.24s;
|
||||||
}
|
}
|
||||||
.dark .thinking-dots .dot {
|
.dark .thinking-dots .dot {
|
||||||
background: #bfdbfe;
|
background: #bfdbfe;
|
||||||
opacity: 0.28;
|
opacity: 0.28;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes think-bounce {
|
@keyframes think-bounce {
|
||||||
0%,
|
0%,
|
||||||
80%,
|
80%,
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
}
|
}
|
||||||
40% {
|
40% {
|
||||||
transform: translateY(-6px);
|
transform: translateY(-6px);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ensure content area doesn't shift when dots appear */
|
/* ensure content area doesn't shift when dots appear */
|
||||||
.content-area {
|
.content-area {
|
||||||
min-height: 1.25rem;
|
min-height: 1.25rem;
|
||||||
}
|
}
|
||||||
.partial-content,
|
.partial-content,
|
||||||
.full-content {
|
.full-content {
|
||||||
transition: opacity 140ms ease;
|
transition: opacity 140ms ease;
|
||||||
}
|
}
|
||||||
.partial-content {
|
.partial-content {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.full-content {
|
.full-content {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vue transition classes for fade */
|
/* Vue transition classes for fade */
|
||||||
.fade-enter-from,
|
.fade-enter-from,
|
||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: opacity 160ms ease;
|
transition: opacity 160ms ease;
|
||||||
}
|
}
|
||||||
.fade-enter-to,
|
.fade-enter-to,
|
||||||
.fade-leave-from {
|
.fade-leave-from {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,369 +1,369 @@
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div v-if="visible" class="modal-overlay" @click.self="close">
|
<div v-if="visible" class="modal-overlay" @click.self="close">
|
||||||
<div class="search-modal">
|
<div class="search-modal">
|
||||||
<!-- 搜索输入 -->
|
<!-- 搜索输入 -->
|
||||||
<div class="search-header">
|
<div class="search-header">
|
||||||
<Search :size="20" class="search-icon" />
|
<Search :size="20" class="search-icon" />
|
||||||
<input
|
<input
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
placeholder="搜索对话..."
|
placeholder="搜索对话..."
|
||||||
@keydown.escape="close"
|
@keydown.escape="close"
|
||||||
@keydown.down.prevent="navigateDown"
|
@keydown.down.prevent="navigateDown"
|
||||||
@keydown.up.prevent="navigateUp"
|
@keydown.up.prevent="navigateUp"
|
||||||
@keydown.enter="selectCurrent"
|
@keydown.enter="selectCurrent"
|
||||||
/>
|
/>
|
||||||
<kbd class="esc-hint">ESC</kbd>
|
<kbd class="esc-hint">ESC</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 搜索结果 -->
|
<!-- 搜索结果 -->
|
||||||
<div class="search-results">
|
<div class="search-results">
|
||||||
<div v-if="filteredConversations.length === 0" class="no-results">
|
<div v-if="filteredConversations.length === 0" class="no-results">
|
||||||
<FolderOpen :size="40" class="no-results-icon" />
|
<FolderOpen :size="40" class="no-results-icon" />
|
||||||
<p>没有找到相关对话</p>
|
<p>没有找到相关对话</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="(conv, index) in filteredConversations"
|
v-for="(conv, index) in filteredConversations"
|
||||||
:key="conv.id"
|
:key="conv.id"
|
||||||
class="result-item"
|
class="result-item"
|
||||||
:class="{ active: index === selectedIndex }"
|
:class="{ active: index === selectedIndex }"
|
||||||
@click="selectConversation(conv.id)"
|
@click="selectConversation(conv.id)"
|
||||||
@mouseenter="selectedIndex = index"
|
@mouseenter="selectedIndex = index"
|
||||||
>
|
>
|
||||||
<MessageSquare :size="18" class="result-icon" />
|
<MessageSquare :size="18" class="result-icon" />
|
||||||
<div class="result-content">
|
<div class="result-content">
|
||||||
<div class="result-title">{{ conv.title }}</div>
|
<div class="result-title">{{ conv.title }}</div>
|
||||||
<div class="result-meta">
|
<div class="result-meta">
|
||||||
<span>{{ conv.messages.length }} 条消息</span>
|
<span>{{ conv.messages.length }} 条消息</span>
|
||||||
<span class="dot">·</span>
|
<span class="dot">·</span>
|
||||||
<span>{{ formatTime(conv.updatedAt) }}</span>
|
<span>{{ formatTime(conv.updatedAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Pin v-if="conv.pinned" :size="14" class="pin-icon" />
|
<Pin v-if="conv.pinned" :size="14" class="pin-icon" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部提示 -->
|
<!-- 底部提示 -->
|
||||||
<div class="search-footer">
|
<div class="search-footer">
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
<kbd>↑↓</kbd>
|
<kbd>↑↓</kbd>
|
||||||
<span>导航</span>
|
<span>导航</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
<kbd>↵</kbd>
|
<kbd>↵</kbd>
|
||||||
<span>选择</span>
|
<span>选择</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
<kbd>ESC</kbd>
|
<kbd>ESC</kbd>
|
||||||
<span>关闭</span>
|
<span>关闭</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { Search, MessageSquare, FolderOpen, Pin } from '@/components/icons'
|
import { Search, MessageSquare, FolderOpen, Pin } from '@/components/icons'
|
||||||
import { formatTimestamp } from '@/utils/helpers'
|
import { formatTimestamp } from '@/utils/helpers'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
const { conversations } = storeToRefs(chatStore)
|
const { conversations } = storeToRefs(chatStore)
|
||||||
const { showSearchModal: visible } = storeToRefs(settingsStore)
|
const { showSearchModal: visible } = storeToRefs(settingsStore)
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const selectedIndex = ref(0)
|
const selectedIndex = ref(0)
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const filteredConversations = computed(() => {
|
const filteredConversations = computed(() => {
|
||||||
if (!searchQuery.value.trim()) {
|
if (!searchQuery.value.trim()) {
|
||||||
return conversations.value.slice(0, 10)
|
return conversations.value.slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
const query = searchQuery.value.toLowerCase()
|
||||||
return conversations.value.filter(conv => {
|
return conversations.value.filter(conv => {
|
||||||
// 搜索标题
|
// 搜索标题
|
||||||
if (conv.title.toLowerCase().includes(query)) return true
|
if (conv.title.toLowerCase().includes(query)) return true
|
||||||
|
|
||||||
// 搜索消息内容
|
// 搜索消息内容
|
||||||
return conv.messages.some(msg =>
|
return conv.messages.some(msg =>
|
||||||
msg.content.text?.toLowerCase().includes(query)
|
msg.content.text?.toLowerCase().includes(query)
|
||||||
)
|
)
|
||||||
}).slice(0, 10)
|
}).slice(0, 10)
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatTime(timestamp: number) {
|
function formatTime(timestamp: number) {
|
||||||
return formatTimestamp(timestamp)
|
return formatTimestamp(timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
settingsStore.closeSearchModal()
|
settingsStore.closeSearchModal()
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
selectedIndex.value = 0
|
selectedIndex.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateDown() {
|
function navigateDown() {
|
||||||
if (selectedIndex.value < filteredConversations.value.length - 1) {
|
if (selectedIndex.value < filteredConversations.value.length - 1) {
|
||||||
selectedIndex.value++
|
selectedIndex.value++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateUp() {
|
function navigateUp() {
|
||||||
if (selectedIndex.value > 0) {
|
if (selectedIndex.value > 0) {
|
||||||
selectedIndex.value--
|
selectedIndex.value--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectCurrent() {
|
function selectCurrent() {
|
||||||
const conv = filteredConversations.value[selectedIndex.value]
|
const conv = filteredConversations.value[selectedIndex.value]
|
||||||
if (conv) {
|
if (conv) {
|
||||||
selectConversation(conv.id)
|
selectConversation(conv.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectConversation(id: string) {
|
function selectConversation(id: string) {
|
||||||
chatStore.selectConversation(id)
|
chatStore.selectConversation(id)
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开时聚焦输入框
|
// 打开时聚焦输入框
|
||||||
watch(visible, (val) => {
|
watch(visible, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
inputRef.value?.focus()
|
inputRef.value?.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 搜索内容变化时重置选中索引
|
// 搜索内容变化时重置选中索引
|
||||||
watch(searchQuery, () => {
|
watch(searchQuery, () => {
|
||||||
selectedIndex.value = 0
|
selectedIndex.value = 0
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 100px;
|
padding-top: 100px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-modal {
|
.search-modal {
|
||||||
width: 560px;
|
width: 560px;
|
||||||
max-height: 480px;
|
max-height: 480px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #1e1e2e;
|
background: #1e1e2e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-header {
|
.search-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
border-bottom-color: #2d2d3d;
|
border-bottom-color: #2d2d3d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.esc-hint {
|
.esc-hint {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #374151;
|
background: #374151;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-results {
|
.search-results {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-results {
|
.no-results {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
|
||||||
.no-results-icon {
|
.no-results-icon {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-item {
|
.result-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&.active {
|
&.active {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #2d2d3d;
|
background: #2d2d3d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-icon {
|
.result-icon {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-content {
|
.result-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-title {
|
.result-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-meta {
|
.result-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pin-icon {
|
.pin-icon {
|
||||||
color: #f59e0b;
|
color: #f59e0b;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-footer {
|
.search-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
border-top: 1px solid #e2e8f0;
|
border-top: 1px solid #e2e8f0;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
border-top-color: #2d2d3d;
|
border-top-color: #2d2d3d;
|
||||||
background: #181825;
|
background: #181825;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
|
||||||
kbd {
|
kbd {
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: white;
|
background: white;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #374151;
|
background: #374151;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过渡动画
|
// 过渡动画
|
||||||
.modal-enter-active,
|
.modal-enter-active,
|
||||||
.modal-leave-active {
|
.modal-leave-active {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
.search-modal {
|
.search-modal {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-enter-from,
|
.modal-enter-from,
|
||||||
.modal-leave-to {
|
.modal-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
||||||
.search-modal {
|
.search-modal {
|
||||||
transform: scale(0.95) translateY(-20px);
|
transform: scale(0.95) translateY(-20px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,307 +1,307 @@
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div v-if="visible" class="modal-overlay" @click.self="close">
|
<div v-if="visible" class="modal-overlay" @click.self="close">
|
||||||
<div class="shortcuts-modal">
|
<div class="shortcuts-modal">
|
||||||
<!-- 头部 -->
|
<!-- 头部 -->
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="header-title">
|
<div class="header-title">
|
||||||
<Keyboard :size="22" />
|
<Keyboard :size="22" />
|
||||||
<h3>键盘快捷键</h3>
|
<h3>键盘快捷键</h3>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" @click="close">
|
<button class="close-btn" @click="close">
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 快捷键列表 -->
|
<!-- 快捷键列表 -->
|
||||||
<div class="shortcuts-content">
|
<div class="shortcuts-content">
|
||||||
<div
|
<div
|
||||||
v-for="group in shortcutGroups"
|
v-for="group in shortcutGroups"
|
||||||
:key="group.title"
|
:key="group.title"
|
||||||
class="shortcut-group"
|
class="shortcut-group"
|
||||||
>
|
>
|
||||||
<h4 class="group-title">{{ group.title }}</h4>
|
<h4 class="group-title">{{ group.title }}</h4>
|
||||||
<div class="shortcuts-list">
|
<div class="shortcuts-list">
|
||||||
<div
|
<div
|
||||||
v-for="shortcut in group.shortcuts"
|
v-for="shortcut in group.shortcuts"
|
||||||
:key="shortcut.description"
|
:key="shortcut.description"
|
||||||
class="shortcut-item"
|
class="shortcut-item"
|
||||||
>
|
>
|
||||||
<span class="shortcut-desc">{{ shortcut.description }}</span>
|
<span class="shortcut-desc">{{ shortcut.description }}</span>
|
||||||
<div class="shortcut-keys">
|
<div class="shortcut-keys">
|
||||||
<kbd v-for="key in shortcut.keys" :key="key">{{ key }}</kbd>
|
<kbd v-for="key in shortcut.keys" :key="key">{{ key }}</kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部 -->
|
<!-- 底部 -->
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<span class="tip">按 <kbd>ESC</kbd> 或 <kbd>?</kbd> 关闭此窗口</span>
|
<span class="tip">按 <kbd>ESC</kbd> 或 <kbd>?</kbd> 关闭此窗口</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { Keyboard, X } from '@/components/icons'
|
import { Keyboard, X } from '@/components/icons'
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const { showShortcutsModal: visible } = storeToRefs(settingsStore)
|
const { showShortcutsModal: visible } = storeToRefs(settingsStore)
|
||||||
|
|
||||||
const shortcutGroups = computed(() => [
|
const shortcutGroups = computed(() => [
|
||||||
{
|
{
|
||||||
title: '通用',
|
title: '通用',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ description: '新建对话', keys: ['⌘', 'N'] },
|
{ description: '新建对话', keys: ['⌘', 'N'] },
|
||||||
{ description: '搜索对话', keys: ['⌘', 'K'] },
|
{ description: '搜索对话', keys: ['⌘', 'K'] },
|
||||||
{ description: '切换侧边栏', keys: ['⌘', 'B'] },
|
{ description: '切换侧边栏', keys: ['⌘', 'B'] },
|
||||||
{ description: '切换主题', keys: ['⌘', '⇧', 'D'] },
|
{ description: '切换主题', keys: ['⌘', '⇧', 'D'] },
|
||||||
{ description: '显示快捷键', keys: ['⌘', '?'] },
|
{ description: '显示快捷键', keys: ['⌘', '?'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '对话',
|
title: '对话',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ description: '发送消息', keys: ['⌘', '↵'] },
|
{ description: '发送消息', keys: ['⌘', '↵'] },
|
||||||
{ description: '换行', keys: ['⇧', '↵'] },
|
{ description: '换行', keys: ['⇧', '↵'] },
|
||||||
{ description: '聚焦输入框', keys: ['⌘', '/'] },
|
{ description: '聚焦输入框', keys: ['⌘', '/'] },
|
||||||
{ description: '停止生成', keys: ['ESC'] },
|
{ description: '停止生成', keys: ['ESC'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '消息操作',
|
title: '消息操作',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ description: '复制消息', keys: ['⌘', 'C'] },
|
{ description: '复制消息', keys: ['⌘', 'C'] },
|
||||||
{ description: '重新生成', keys: ['⌘', 'R'] },
|
{ description: '重新生成', keys: ['⌘', 'R'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
settingsStore.closeShortcutsModal()
|
settingsStore.closeShortcutsModal()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcuts-modal {
|
.shortcuts-modal {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #1e1e2e;
|
background: #1e1e2e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
border-bottom-color: #2d2d3d;
|
border-bottom-color: #2d2d3d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #374151;
|
background: #374151;
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcuts-content {
|
.shortcuts-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcut-group {
|
.shortcut-group {
|
||||||
&:not(:last-child) {
|
&:not(:last-child) {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-title {
|
.group-title {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcuts-list {
|
.shortcuts-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcut-item {
|
.shortcut-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #2d2d3d;
|
background: #2d2d3d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcut-desc {
|
.shortcut-desc {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcut-keys {
|
.shortcut-keys {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
||||||
kbd {
|
kbd {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
background: #1e1e2e;
|
background: #1e1e2e;
|
||||||
border-color: #4b5563;
|
border-color: #4b5563;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
border-top: 1px solid #e2e8f0;
|
border-top: 1px solid #e2e8f0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
border-top-color: #2d2d3d;
|
border-top-color: #2d2d3d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tip {
|
.tip {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
|
||||||
kbd {
|
kbd {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 24px;
|
min-width: 24px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #374151;
|
background: #374151;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过渡动画
|
// 过渡动画
|
||||||
.modal-enter-active,
|
.modal-enter-active,
|
||||||
.modal-leave-active {
|
.modal-leave-active {
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
|
|
||||||
.shortcuts-modal {
|
.shortcuts-modal {
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-enter-from,
|
.modal-enter-from,
|
||||||
.modal-leave-to {
|
.modal-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
||||||
.shortcuts-modal {
|
.shortcuts-modal {
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,497 +1,497 @@
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
class="chat-sidebar"
|
class="chat-sidebar"
|
||||||
:class="{ collapsed: isCollapsed }"
|
:class="{ collapsed: isCollapsed }"
|
||||||
:style="{ width: isCollapsed ? '0px' : `${sidebarWidth}px` }"
|
:style="{ width: isCollapsed ? '0px' : `${sidebarWidth}px` }"
|
||||||
>
|
>
|
||||||
<div class="sidebar-inner">
|
<div class="sidebar-inner">
|
||||||
<!-- 头部 -->
|
<!-- 头部 -->
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<Bot :size="24" class="logo-icon" />
|
<Bot :size="24" class="logo-icon" />
|
||||||
<span v-show="!isCollapsed" class="logo-text">AI Chat</span>
|
<span v-show="!isCollapsed" class="logo-text">AI Chat</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="collapse-btn"
|
class="collapse-btn"
|
||||||
@click="toggleSidebar"
|
@click="toggleSidebar"
|
||||||
:title="isCollapsed ? '展开侧边栏' : '收起侧边栏'"
|
:title="isCollapsed ? '展开侧边栏' : '收起侧边栏'"
|
||||||
>
|
>
|
||||||
<ChevronLeft :size="18" :class="{ rotated: isCollapsed }" />
|
<ChevronLeft :size="18" :class="{ rotated: isCollapsed }" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 新建对话按钮 -->
|
<!-- 新建对话按钮 -->
|
||||||
<div class="new-chat-section">
|
<div class="new-chat-section">
|
||||||
<button class="new-chat-btn" @click="handleNewChat">
|
<button class="new-chat-btn" @click="handleNewChat">
|
||||||
<Plus :size="18" />
|
<Plus :size="18" />
|
||||||
<span>新建对话</span>
|
<span>新建对话</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 搜索框 -->
|
<!-- 搜索框 -->
|
||||||
<div class="search-section">
|
<div class="search-section">
|
||||||
<div class="search-box" @click="openSearch">
|
<div class="search-box" @click="openSearch">
|
||||||
<Search :size="16" />
|
<Search :size="16" />
|
||||||
<span class="search-placeholder">搜索对话...</span>
|
<span class="search-placeholder">搜索对话...</span>
|
||||||
<kbd class="search-kbd">⌘K</kbd>
|
<kbd class="search-kbd">⌘K</kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 对话列表 -->
|
<!-- 对话列表 -->
|
||||||
<div class="conversations-section">
|
<div class="conversations-section">
|
||||||
<!-- 置顶对话 -->
|
<!-- 置顶对话 -->
|
||||||
<div v-if="pinnedConversations.length > 0" class="conversation-group">
|
<div v-if="pinnedConversations.length > 0" class="conversation-group">
|
||||||
<div class="group-header">
|
<div class="group-header">
|
||||||
<Pin :size="14" />
|
<Pin :size="14" />
|
||||||
<span>置顶</span>
|
<span>置顶</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="group-list">
|
<div class="group-list">
|
||||||
<ConversationItem
|
<ConversationItem
|
||||||
v-for="conv in pinnedConversations"
|
v-for="conv in pinnedConversations"
|
||||||
:key="conv.id"
|
:key="conv.id"
|
||||||
:conversation="conv"
|
:conversation="conv"
|
||||||
:is-active="conv.id === currentConversationId"
|
:is-active="conv.id === currentConversationId"
|
||||||
@select="selectConversation"
|
@select="selectConversation"
|
||||||
@delete="deleteConversation"
|
@delete="deleteConversation"
|
||||||
@rename="renameConversation"
|
@rename="renameConversation"
|
||||||
@toggle-pin="togglePinConversation"
|
@toggle-pin="togglePinConversation"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 最近对话 -->
|
<!-- 最近对话 -->
|
||||||
<div class="conversation-group">
|
<div class="conversation-group">
|
||||||
<div class="group-header">
|
<div class="group-header">
|
||||||
<Clock :size="14" />
|
<Clock :size="14" />
|
||||||
<span>最近</span>
|
<span>最近</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="group-list">
|
<div class="group-list">
|
||||||
<ConversationItem
|
<ConversationItem
|
||||||
v-for="conv in recentConversations"
|
v-for="conv in recentConversations"
|
||||||
:key="conv.id"
|
:key="conv.id"
|
||||||
:conversation="conv"
|
:conversation="conv"
|
||||||
:is-active="conv.id === currentConversationId"
|
:is-active="conv.id === currentConversationId"
|
||||||
@select="selectConversation"
|
@select="selectConversation"
|
||||||
@delete="deleteConversation"
|
@delete="deleteConversation"
|
||||||
@rename="renameConversation"
|
@rename="renameConversation"
|
||||||
@toggle-pin="togglePinConversation"
|
@toggle-pin="togglePinConversation"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div
|
<div
|
||||||
v-if="pinnedConversations.length === 0 && recentConversations.length === 0"
|
v-if="pinnedConversations.length === 0 && recentConversations.length === 0"
|
||||||
class="empty-state"
|
class="empty-state"
|
||||||
>
|
>
|
||||||
<MessageSquare :size="40" class="empty-icon" />
|
<MessageSquare :size="40" class="empty-icon" />
|
||||||
<p>暂无对话</p>
|
<p>暂无对话</p>
|
||||||
<span>点击上方按钮开始新对话</span>
|
<span>点击上方按钮开始新对话</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部操作 -->
|
<!-- 底部操作 -->
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="footer-btn" @click="toggleTheme" title="切换主题">
|
<button class="footer-btn" @click="toggleTheme" title="切换主题">
|
||||||
<Sun v-if="currentTheme === 'light'" :size="18" />
|
<Sun v-if="currentTheme === 'light'" :size="18" />
|
||||||
<Moon v-else-if="currentTheme === 'dark'" :size="18" />
|
<Moon v-else-if="currentTheme === 'dark'" :size="18" />
|
||||||
<Monitor v-else :size="18" />
|
<Monitor v-else :size="18" />
|
||||||
</button>
|
</button>
|
||||||
<button class="footer-btn" @click="openShortcuts" title="快捷键">
|
<button class="footer-btn" @click="openShortcuts" title="快捷键">
|
||||||
<Keyboard :size="18" />
|
<Keyboard :size="18" />
|
||||||
</button>
|
</button>
|
||||||
<button class="footer-btn" @click="openSettings" title="设置">
|
<button class="footer-btn" @click="openSettings" title="设置">
|
||||||
<Settings :size="18" />
|
<Settings :size="18" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 拖拽调整宽度 -->
|
<!-- 拖拽调整宽度 -->
|
||||||
<div
|
<div
|
||||||
class="resize-handle"
|
class="resize-handle"
|
||||||
@mousedown="startResize"
|
@mousedown="startResize"
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import ConversationItem from './ConversationItem.vue'
|
import ConversationItem from './ConversationItem.vue'
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Pin,
|
Pin,
|
||||||
Clock,
|
Clock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
Monitor,
|
Monitor,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
} from '@/components/icons'
|
} from '@/components/icons'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentConversationId,
|
currentConversationId,
|
||||||
pinnedConversations,
|
pinnedConversations,
|
||||||
recentConversations
|
recentConversations
|
||||||
} = storeToRefs(chatStore)
|
} = storeToRefs(chatStore)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sidebarCollapsed: isCollapsed,
|
sidebarCollapsed: isCollapsed,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
settings
|
settings
|
||||||
} = storeToRefs(settingsStore)
|
} = storeToRefs(settingsStore)
|
||||||
|
|
||||||
const currentTheme = computed(() => settings.value.theme)
|
const currentTheme = computed(() => settings.value.theme)
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
function handleNewChat() {
|
function handleNewChat() {
|
||||||
chatStore.createConversation()
|
chatStore.createConversation()
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectConversation(id: string) {
|
function selectConversation(id: string) {
|
||||||
chatStore.selectConversation(id)
|
chatStore.selectConversation(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteConversation(id: string) {
|
function deleteConversation(id: string) {
|
||||||
chatStore.deleteConversation(id)
|
chatStore.deleteConversation(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renameConversation(id: string, title: string) {
|
function renameConversation(id: string, title: string) {
|
||||||
chatStore.renameConversation(id, title)
|
chatStore.renameConversation(id, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePinConversation(id: string) {
|
function togglePinConversation(id: string) {
|
||||||
chatStore.togglePinConversation(id)
|
chatStore.togglePinConversation(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
settingsStore.toggleSidebar()
|
settingsStore.toggleSidebar()
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
settingsStore.toggleTheme()
|
settingsStore.toggleTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShortcuts() {
|
function openShortcuts() {
|
||||||
settingsStore.openShortcutsModal()
|
settingsStore.openShortcutsModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
settingsStore.openSettingsModal()
|
settingsStore.openSettingsModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSearch() {
|
function openSearch() {
|
||||||
settingsStore.openSearchModal()
|
settingsStore.openSearchModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖拽调整宽度
|
// 拖拽调整宽度
|
||||||
const isResizing = ref(false)
|
const isResizing = ref(false)
|
||||||
|
|
||||||
function startResize(e: MouseEvent) {
|
function startResize(e: MouseEvent) {
|
||||||
isResizing.value = true
|
isResizing.value = true
|
||||||
const startX = e.clientX
|
const startX = e.clientX
|
||||||
const startWidth = sidebarWidth.value
|
const startWidth = sidebarWidth.value
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
const diff = e.clientX - startX
|
const diff = e.clientX - startX
|
||||||
settingsStore.setSidebarWidth(startWidth + diff)
|
settingsStore.setSidebarWidth(startWidth + diff)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
isResizing.value = false
|
isResizing.value = false
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
document.removeEventListener('mouseup', handleMouseUp)
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.chat-sidebar {
|
.chat-sidebar {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-right: 1px solid #e2e8f0;
|
border-right: 1px solid #e2e8f0;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #1e1e2e;
|
background: #1e1e2e;
|
||||||
border-right-color: #2d2d3d;
|
border-right-color: #2d2d3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.collapsed {
|
&.collapsed {
|
||||||
.sidebar-inner {
|
.sidebar-inner {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-inner {
|
.sidebar-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
border-bottom-color: #2d2d3d;
|
border-bottom-color: #2d2d3d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon {
|
.logo-icon {
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse-btn {
|
.collapse-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
color: #374151;
|
color: #374151;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
&.rotated {
|
&.rotated {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-chat-section {
|
.new-chat-section {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-chat-btn {
|
.new-chat-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border: 1px dashed #d1d5db;
|
border: 1px dashed #d1d5db;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
border-color: #4b5563;
|
border-color: #4b5563;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
background: rgba(59, 130, 246, 0.05);
|
background: rgba(59, 130, 246, 0.05);
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-section {
|
.search-section {
|
||||||
padding: 0 16px 12px;
|
padding: 0 16px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box {
|
.search-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: rgba(0, 0, 0, 0.03);
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-placeholder {
|
.search-placeholder {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-kbd {
|
.search-kbd {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversations-section {
|
.conversations-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-group {
|
.conversation-group {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-header {
|
.group-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
color: #d1d5db;
|
color: #d1d5db;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-top: 1px solid #e2e8f0;
|
border-top: 1px solid #e2e8f0;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
border-top-color: #2d2d3d;
|
border-top-color: #2d2d3d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-btn {
|
.footer-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
color: #374151;
|
color: #374151;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: -3px;
|
right: -3px;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(59, 130, 246, 0.3);
|
background: rgba(59, 130, 246, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,278 +1,278 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="conversation-item group"
|
class="conversation-item group"
|
||||||
:class="{
|
:class="{
|
||||||
'active': isActive,
|
'active': isActive,
|
||||||
'pinned': conversation.pinned
|
'pinned': conversation.pinned
|
||||||
}"
|
}"
|
||||||
@click="handleSelect"
|
@click="handleSelect"
|
||||||
@dblclick="handleRename"
|
@dblclick="handleRename"
|
||||||
>
|
>
|
||||||
<!-- 图标 -->
|
<!-- 图标 -->
|
||||||
<div class="item-icon">
|
<div class="item-icon">
|
||||||
<MessageSquare :size="18" />
|
<MessageSquare :size="18" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内容 -->
|
<!-- 内容 -->
|
||||||
<div class="item-content">
|
<div class="item-content">
|
||||||
<div v-if="!isEditing" class="item-title">
|
<div v-if="!isEditing" class="item-title">
|
||||||
{{ conversation.title }}
|
{{ conversation.title }}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
v-model="editTitle"
|
v-model="editTitle"
|
||||||
class="item-title-input"
|
class="item-title-input"
|
||||||
@blur="handleSaveRename"
|
@blur="handleSaveRename"
|
||||||
@keydown.enter="handleSaveRename"
|
@keydown.enter="handleSaveRename"
|
||||||
@keydown.escape="handleCancelRename"
|
@keydown.escape="handleCancelRename"
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
<div class="item-meta">
|
<div class="item-meta">
|
||||||
<Clock :size="12" />
|
<Clock :size="12" />
|
||||||
<span>{{ formattedTime }}</span>
|
<span>{{ formattedTime }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 置顶标识 -->
|
<!-- 置顶标识 -->
|
||||||
<div v-if="conversation.pinned" class="pin-indicator">
|
<div v-if="conversation.pinned" class="pin-indicator">
|
||||||
<Pin :size="12" />
|
<Pin :size="12" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div class="item-actions" @click.stop>
|
<div class="item-actions" @click.stop>
|
||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
:title="conversation.pinned ? '取消置顶' : '置顶'"
|
:title="conversation.pinned ? '取消置顶' : '置顶'"
|
||||||
@click="handleTogglePin"
|
@click="handleTogglePin"
|
||||||
>
|
>
|
||||||
<PinOff v-if="conversation.pinned" :size="14" />
|
<PinOff v-if="conversation.pinned" :size="14" />
|
||||||
<Pin v-else :size="14" />
|
<Pin v-else :size="14" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
title="重命名"
|
title="重命名"
|
||||||
@click="handleRename"
|
@click="handleRename"
|
||||||
>
|
>
|
||||||
<Edit3 :size="14" />
|
<Edit3 :size="14" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="action-btn delete"
|
class="action-btn delete"
|
||||||
title="删除"
|
title="删除"
|
||||||
@click="handleDelete"
|
@click="handleDelete"
|
||||||
>
|
>
|
||||||
<Trash2 :size="14" />
|
<Trash2 :size="14" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick } from 'vue'
|
import { ref, computed, nextTick } from 'vue'
|
||||||
import { MessageSquare, Pin, PinOff, Edit3, Trash2, Clock } from '@/components/icons'
|
import { MessageSquare, Pin, PinOff, Edit3, Trash2, Clock } from '@/components/icons'
|
||||||
import { formatTimestamp } from '@/utils/helpers'
|
import { formatTimestamp } from '@/utils/helpers'
|
||||||
import type { Conversation } from '@/types/chat'
|
import type { Conversation } from '@/types/chat'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
conversation: Conversation
|
conversation: Conversation
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [id: string]
|
select: [id: string]
|
||||||
delete: [id: string]
|
delete: [id: string]
|
||||||
rename: [id: string, title: string]
|
rename: [id: string, title: string]
|
||||||
togglePin: [id: string]
|
togglePin: [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isEditing = ref(false)
|
const isEditing = ref(false)
|
||||||
const editTitle = ref('')
|
const editTitle = ref('')
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const formattedTime = computed(() => {
|
const formattedTime = computed(() => {
|
||||||
return formatTimestamp(props.conversation.updatedAt)
|
return formatTimestamp(props.conversation.updatedAt)
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleSelect() {
|
function handleSelect() {
|
||||||
if (!isEditing.value) {
|
if (!isEditing.value) {
|
||||||
emit('select', props.conversation.id)
|
emit('select', props.conversation.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTogglePin() {
|
function handleTogglePin() {
|
||||||
emit('togglePin', props.conversation.id)
|
emit('togglePin', props.conversation.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRename() {
|
function handleRename() {
|
||||||
isEditing.value = true
|
isEditing.value = true
|
||||||
editTitle.value = props.conversation.title
|
editTitle.value = props.conversation.title
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
inputRef.value?.focus()
|
inputRef.value?.focus()
|
||||||
inputRef.value?.select()
|
inputRef.value?.select()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSaveRename() {
|
function handleSaveRename() {
|
||||||
if (editTitle.value.trim()) {
|
if (editTitle.value.trim()) {
|
||||||
emit('rename', props.conversation.id, editTitle.value.trim())
|
emit('rename', props.conversation.id, editTitle.value.trim())
|
||||||
}
|
}
|
||||||
isEditing.value = false
|
isEditing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancelRename() {
|
function handleCancelRename() {
|
||||||
isEditing.value = false
|
isEditing.value = false
|
||||||
editTitle.value = ''
|
editTitle.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (confirm('确定要删除这个对话吗?')) {
|
if (confirm('确定要删除这个对话吗?')) {
|
||||||
emit('delete', props.conversation.id)
|
emit('delete', props.conversation.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.conversation-item {
|
.conversation-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
margin: 2px 8px;
|
margin: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-actions {
|
.item-actions {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pin-indicator {
|
.pin-indicator {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: rgba(59, 130, 246, 0.2);
|
background: rgba(59, 130, 246, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-icon {
|
.item-icon {
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-icon {
|
.item-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-content {
|
.item-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-title {
|
.item-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-title-input {
|
.item-title-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #3b82f6;
|
border: 1px solid #3b82f6;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
background: #374151;
|
background: #374151;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-meta {
|
.item-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pin-indicator {
|
.pin-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
color: #f59e0b;
|
color: #f59e0b;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-actions {
|
.item-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.1);
|
background: rgba(0, 0, 0, 0.1);
|
||||||
color: #374151;
|
color: #374151;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.delete:hover {
|
&.delete:hover {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,242 +1,242 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="form-select" :class="{ open: isOpen, disabled }">
|
<div class="form-select" :class="{ open: isOpen, disabled }">
|
||||||
<button class="select-trigger" :disabled="disabled" @click="toggleOpen">
|
<button class="select-trigger" :disabled="disabled" @click="toggleOpen">
|
||||||
<span class="select-value">
|
<span class="select-value">
|
||||||
<slot name="selected" :option="selectedOption">
|
<slot name="selected" :option="selectedOption">
|
||||||
{{ selectedOption?.label || placeholder }}
|
{{ selectedOption?.label || placeholder }}
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown :size="18" class="select-arrow" />
|
<ChevronDown :size="18" class="select-arrow" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Transition name="dropdown">
|
<Transition name="dropdown">
|
||||||
<div v-if="isOpen" class="select-dropdown">
|
<div v-if="isOpen" class="select-dropdown">
|
||||||
<div
|
<div
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="select-option"
|
class="select-option"
|
||||||
:class="{ active: option.value === modelValue }"
|
:class="{ active: option.value === modelValue }"
|
||||||
@click="selectOption(option)"
|
@click="selectOption(option)"
|
||||||
>
|
>
|
||||||
<slot name="option" :option="option">
|
<slot name="option" :option="option">
|
||||||
<span class="option-label">{{ option.label }}</span>
|
<span class="option-label">{{ option.label }}</span>
|
||||||
<span v-if="option.description" class="option-desc">{{
|
<span v-if="option.description" class="option-desc">{{
|
||||||
option.description
|
option.description
|
||||||
}}</span>
|
}}</span>
|
||||||
</slot>
|
</slot>
|
||||||
<Check
|
<Check
|
||||||
v-if="option.value === modelValue"
|
v-if="option.value === modelValue"
|
||||||
:size="16"
|
:size="16"
|
||||||
class="check-icon"
|
class="check-icon"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
import { ChevronDown, Check } from "@/components/icons";
|
import { ChevronDown, Check } from "@/components/icons";
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
value: string | number;
|
value: string | number;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: string | number;
|
modelValue: string | number;
|
||||||
options: SelectOption[];
|
options: SelectOption[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
valueProp?: string;
|
valueProp?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
placeholder: "请选择",
|
placeholder: "请选择",
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"update:modelValue": [value: string | number];
|
"update:modelValue": [value: string | number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
|
|
||||||
const selectedOption = computed(() => {
|
const selectedOption = computed(() => {
|
||||||
return props.options.find((opt) => opt.value === props.modelValue);
|
return props.options.find((opt) => opt.value === props.modelValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleOpen() {
|
function toggleOpen() {
|
||||||
if (!props.disabled) {
|
if (!props.disabled) {
|
||||||
isOpen.value = !isOpen.value;
|
isOpen.value = !isOpen.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectOption(option: any) {
|
function selectOption(option: any) {
|
||||||
emit("update:modelValue", option[props.valueProp || "value"]);
|
emit("update:modelValue", option[props.valueProp || "value"]);
|
||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (!target.closest(".form-select")) {
|
if (!target.closest(".form-select")) {
|
||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener("click", handleClickOutside);
|
document.addEventListener("click", handleClickOutside);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener("click", handleClickOutside);
|
document.removeEventListener("click", handleClickOutside);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.form-select {
|
.form-select {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
.select-arrow {
|
.select-arrow {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-trigger {
|
.select-trigger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #2d2d3d;
|
background: #2d2d3d;
|
||||||
border-color: #374151;
|
border-color: #374151;
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-value {
|
.select-value {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-arrow {
|
.select-arrow {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-dropdown {
|
.select-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 6px);
|
top: calc(100% + 6px);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
max-height: 280px;
|
max-height: 280px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #1e1e2e;
|
background: #1e1e2e;
|
||||||
border-color: #2d2d3d;
|
border-color: #2d2d3d;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-option {
|
.select-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
&:first-child {
|
&:first-child {
|
||||||
border-radius: 11px 11px 0 0;
|
border-radius: 11px 11px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-radius: 0 0 11px 11px;
|
border-radius: 0 0 11px 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #2d2d3d;
|
background: #2d2d3d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
|
||||||
.option-label {
|
.option-label {
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-label {
|
.option-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-desc {
|
.option-desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-icon {
|
.check-icon {
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下拉动画
|
// 下拉动画
|
||||||
.dropdown-enter-active,
|
.dropdown-enter-active,
|
||||||
.dropdown-leave-active {
|
.dropdown-leave-active {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-enter-from,
|
.dropdown-enter-from,
|
||||||
.dropdown-leave-to {
|
.dropdown-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,116 +1,116 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="form-slider">
|
<div class="form-slider">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:min="min"
|
:min="min"
|
||||||
:max="max"
|
:max="max"
|
||||||
:step="step"
|
:step="step"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
/>
|
/>
|
||||||
<div class="slider-track">
|
<div class="slider-track">
|
||||||
<div class="slider-fill" :style="{ width: fillPercent + '%' }"></div>
|
<div class="slider-fill" :style="{ width: fillPercent + '%' }"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
modelValue: number
|
modelValue: number
|
||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
step?: number
|
step?: number
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
step: 1,
|
step: 1,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: number]
|
'update:modelValue': [value: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const fillPercent = computed(() => {
|
const fillPercent = computed(() => {
|
||||||
return ((props.modelValue - props.min) / (props.max - props.min)) * 100
|
return ((props.modelValue - props.min) / (props.max - props.min)) * 100
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleInput(event: Event) {
|
function handleInput(event: Event) {
|
||||||
const value = parseFloat((event.target as HTMLInputElement).value)
|
const value = parseFloat((event.target as HTMLInputElement).value)
|
||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.form-slider {
|
.form-slider {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-track {
|
.slider-track {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #374151;
|
background: #374151;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-fill {
|
.slider-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: width 0.1s ease;
|
transition: width 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滑块样式(不同浏览器)
|
// 滑块样式(不同浏览器)
|
||||||
.form-slider input[type="range"]::-webkit-slider-thumb {
|
.form-slider input[type="range"]::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-slider input[type="range"]::-moz-range-thumb {
|
.form-slider input[type="range"]::-moz-range-thumb {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: white;
|
background: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,80 +1,80 @@
|
||||||
<template>
|
<template>
|
||||||
<label class="form-switch" :class="{ disabled }">
|
<label class="form-switch" :class="{ disabled }">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="modelValue"
|
:checked="modelValue"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
|
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
|
||||||
/>
|
/>
|
||||||
<span class="switch-slider"></span>
|
<span class="switch-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.form-switch {
|
.form-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|
||||||
&:checked + .switch-slider {
|
&:checked + .switch-slider {
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
transform: translateX(20px);
|
transform: translateX(20px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus + .switch-slider {
|
&:focus + .switch-slider {
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch-slider {
|
.switch-slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: #d1d5db;
|
background: #d1d5db;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #4b5563;
|
background: #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
left: 2px;
|
left: 2px;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,128 +1,128 @@
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
export interface KeyboardShortcut {
|
export interface KeyboardShortcut {
|
||||||
key: string
|
key: string
|
||||||
ctrl?: boolean
|
ctrl?: boolean
|
||||||
shift?: boolean
|
shift?: boolean
|
||||||
alt?: boolean
|
alt?: boolean
|
||||||
meta?: boolean
|
meta?: boolean
|
||||||
description: string
|
description: string
|
||||||
action: () => void
|
action: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// 快捷键管理组合式函数
|
// 快捷键管理组合式函数
|
||||||
export function useKeyboard(shortcuts: KeyboardShortcut[]) {
|
export function useKeyboard(shortcuts: KeyboardShortcut[]) {
|
||||||
const activeKeys = ref<Set<string>>(new Set())
|
const activeKeys = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
activeKeys.value.add(event.key.toLowerCase())
|
activeKeys.value.add(event.key.toLowerCase())
|
||||||
|
|
||||||
for (const shortcut of shortcuts) {
|
for (const shortcut of shortcuts) {
|
||||||
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
|
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
|
||||||
const ctrlMatch = !!shortcut.ctrl === (event.ctrlKey || event.metaKey)
|
const ctrlMatch = !!shortcut.ctrl === (event.ctrlKey || event.metaKey)
|
||||||
const shiftMatch = !!shortcut.shift === event.shiftKey
|
const shiftMatch = !!shortcut.shift === event.shiftKey
|
||||||
const altMatch = !!shortcut.alt === event.altKey
|
const altMatch = !!shortcut.alt === event.altKey
|
||||||
|
|
||||||
if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
|
if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
|
||||||
// 排除在输入框中的部分快捷键
|
// 排除在输入框中的部分快捷键
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
const isInput = target.tagName === 'INPUT' ||
|
const isInput = target.tagName === 'INPUT' ||
|
||||||
target.tagName === 'TEXTAREA' ||
|
target.tagName === 'TEXTAREA' ||
|
||||||
target.isContentEditable
|
target.isContentEditable
|
||||||
|
|
||||||
// 这些快捷键在输入框中也生效
|
// 这些快捷键在输入框中也生效
|
||||||
const globalShortcuts = ['Escape', 'Enter']
|
const globalShortcuts = ['Escape', 'Enter']
|
||||||
const needsModifier = shortcut.ctrl || shortcut.alt || shortcut.meta
|
const needsModifier = shortcut.ctrl || shortcut.alt || shortcut.meta
|
||||||
|
|
||||||
if (isInput && !globalShortcuts.includes(shortcut.key) && !needsModifier) {
|
if (isInput && !globalShortcuts.includes(shortcut.key) && !needsModifier) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
shortcut.action()
|
shortcut.action()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyUp = (event: KeyboardEvent) => {
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
activeKeys.value.delete(event.key.toLowerCase())
|
activeKeys.value.delete(event.key.toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
window.addEventListener('keyup', handleKeyUp)
|
window.addEventListener('keyup', handleKeyUp)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', handleKeyDown)
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
window.removeEventListener('keyup', handleKeyUp)
|
window.removeEventListener('keyup', handleKeyUp)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeKeys,
|
activeKeys,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预定义的快捷键配置
|
// 预定义的快捷键配置
|
||||||
export function getDefaultShortcuts(actions: {
|
export function getDefaultShortcuts(actions: {
|
||||||
newChat: () => void
|
newChat: () => void
|
||||||
toggleSidebar: () => void
|
toggleSidebar: () => void
|
||||||
focusInput: () => void
|
focusInput: () => void
|
||||||
sendMessage: () => void
|
sendMessage: () => void
|
||||||
cancelStream: () => void
|
cancelStream: () => void
|
||||||
toggleTheme: () => void
|
toggleTheme: () => void
|
||||||
showShortcuts: () => void
|
showShortcuts: () => void
|
||||||
searchConversations: () => void
|
searchConversations: () => void
|
||||||
}): KeyboardShortcut[] {
|
}): KeyboardShortcut[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: 'n',
|
key: 'n',
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
description: '新建对话',
|
description: '新建对话',
|
||||||
action: actions.newChat,
|
action: actions.newChat,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'b',
|
key: 'b',
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
description: '切换侧边栏',
|
description: '切换侧边栏',
|
||||||
action: actions.toggleSidebar,
|
action: actions.toggleSidebar,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '/',
|
key: '/',
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
description: '聚焦输入框',
|
description: '聚焦输入框',
|
||||||
action: actions.focusInput,
|
action: actions.focusInput,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Enter',
|
key: 'Enter',
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
description: '发送消息',
|
description: '发送消息',
|
||||||
action: actions.sendMessage,
|
action: actions.sendMessage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Escape',
|
key: 'Escape',
|
||||||
description: '取消生成',
|
description: '取消生成',
|
||||||
action: actions.cancelStream,
|
action: actions.cancelStream,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'd',
|
key: 'd',
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
shift: true,
|
shift: true,
|
||||||
description: '切换主题',
|
description: '切换主题',
|
||||||
action: actions.toggleTheme,
|
action: actions.toggleTheme,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '?',
|
key: '?',
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
description: '显示快捷键',
|
description: '显示快捷键',
|
||||||
action: actions.showShortcuts,
|
action: actions.showShortcuts,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'k',
|
key: 'k',
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
description: '搜索对话',
|
description: '搜索对话',
|
||||||
action: actions.searchConversations,
|
action: actions.searchConversations,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
52
src/main.ts
52
src/main.ts
|
|
@ -1,26 +1,26 @@
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
|
|
||||||
// 样式
|
// 样式
|
||||||
import "@unocss/reset/tailwind.css";
|
import "@unocss/reset/tailwind.css";
|
||||||
import "virtual:uno.css";
|
import "virtual:uno.css";
|
||||||
import "./styles/main.scss";
|
import "./styles/main.scss";
|
||||||
import "markstream-vue/index.css";
|
import "markstream-vue/index.css";
|
||||||
|
|
||||||
// 创建应用
|
// 创建应用
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
// 使用 Pinia
|
// 使用 Pinia
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
|
|
||||||
// 挂载应用
|
// 挂载应用
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|
||||||
// 类型声明
|
// 类型声明
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
$toast: (message: string, type?: "success" | "error" | "info") => void;
|
$toast: (message: string, type?: "success" | "error" | "info") => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,209 +1,238 @@
|
||||||
/**
|
/**
|
||||||
* Chat UI API 服务
|
* Chat UI API 服务
|
||||||
* 所有端点都是固定的,后端需要实现这些端点
|
* 所有端点都是固定的,后端需要实现这些端点
|
||||||
*/
|
*/
|
||||||
// API 端点定义(固定)
|
// API 端点定义(固定)
|
||||||
const API_ENDPOINTS = {
|
const API_ENDPOINTS = {
|
||||||
// 发送消息(流式)
|
// 发送消息(流式)
|
||||||
CHAT_STREAM: "/api/chat-ui/chat",
|
CHAT_STREAM: "/api/chat-ui/chat",
|
||||||
// 发送消息(非流式)
|
// 发送消息(非流式)
|
||||||
CHAT: "/api/chat-ui/chat",
|
CHAT: "/api/chat-ui/chat",
|
||||||
// 获取对话历史
|
// 获取对话历史
|
||||||
CONVERSATIONS: "/api/chat-ui/conversations",
|
CONVERSATIONS: "/api/chat-ui/conversations",
|
||||||
// 获取单个对话
|
// 获取单个对话
|
||||||
CONVERSATION: "/api/chat-ui/conversations/:id",
|
CONVERSATION: "/api/chat-ui/conversations/:id",
|
||||||
// 删除对话
|
// 删除对话
|
||||||
DELETE_CONVERSATION: "/api/chat-ui/conversations/:id",
|
DELETE_CONVERSATION: "/api/chat-ui/conversations/:id",
|
||||||
// 上传文件
|
// 上传文件
|
||||||
UPLOAD: "/api/chat-ui/upload",
|
UPLOAD: "/api/chat-ui/upload",
|
||||||
// 获取模型列表
|
// 获取模型列表
|
||||||
MODELS: "/api/chat-ui/models",
|
MODELS: "/api/chat-ui/models",
|
||||||
// 停止生成
|
// 停止生成
|
||||||
STOP: "/api/chat-ui/stop",
|
STOP: "/api/chat-ui/stop",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 请求类型定义
|
// 请求类型定义
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: "user" | "assistant" | "system";
|
role: "user" | "assistant" | "system";
|
||||||
content: string;
|
content: string;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
files?: string[];
|
files?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatRequest {
|
export interface ChatRequest {
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
message: string;
|
message: string;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
files?: string[];
|
files?: string[];
|
||||||
model?: string;
|
model?: string;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
// 扩展选项
|
// 扩展选项
|
||||||
deepSearch?: boolean;
|
deepSearch?: boolean;
|
||||||
webSearch?: boolean;
|
webSearch?: boolean;
|
||||||
deepThinking?: boolean;
|
deepThinking?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponse {
|
export interface ChatResponse {
|
||||||
id: string;
|
id: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
content: string;
|
content: string;
|
||||||
model: string;
|
model: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
usage?: {
|
usage?: {
|
||||||
promptTokens: number;
|
promptTokens: number;
|
||||||
completionTokens: number;
|
completionTokens: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
provider: string;
|
provider: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadResult {
|
export interface UploadResult {
|
||||||
url: string;
|
url: string;
|
||||||
name: string;
|
name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 调用类
|
// API 调用类
|
||||||
class ChatApi {
|
class ChatApi {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
|
||||||
constructor(baseUrl = "") {
|
constructor(baseUrl = "") {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 流式对话
|
* 流式对话
|
||||||
*/
|
*/
|
||||||
async *streamChat(
|
async *streamChat(
|
||||||
request: ChatRequest,
|
request: ChatRequest,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): AsyncGenerator<string> {
|
): AsyncGenerator<string> {
|
||||||
const response = await fetch(
|
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
|
||||||
`${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`,
|
const openAiRequest = {
|
||||||
{
|
model: request.model || "qwen-plus",
|
||||||
method: "POST",
|
messages: [
|
||||||
headers: {
|
{ role: "system", content: request.systemPrompt || "你是一个有用的助手。" },
|
||||||
"Content-Type": "application/json",
|
{ role: "user", content: request.message }
|
||||||
Accept: "text/event-stream",
|
],
|
||||||
},
|
stream: true,
|
||||||
body: JSON.stringify(request),
|
temperature: request.temperature,
|
||||||
signal,
|
max_tokens: request.maxTokens
|
||||||
},
|
};
|
||||||
);
|
|
||||||
|
const response = await fetch(
|
||||||
if (!response.ok) {
|
`${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`,
|
||||||
const error = await response.text();
|
{
|
||||||
throw new Error(error || `HTTP ${response.status}`);
|
method: "POST",
|
||||||
}
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
const reader = response.body?.getReader();
|
Accept: "text/event-stream",
|
||||||
if (!reader) {
|
},
|
||||||
throw new Error("Response body is not readable");
|
body: JSON.stringify(openAiRequest),
|
||||||
}
|
signal,
|
||||||
|
},
|
||||||
const decoder = new TextDecoder();
|
);
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
if (!response.ok) {
|
||||||
if (done) break;
|
const error = await response.text();
|
||||||
|
throw new Error(error || `HTTP ${response.status}`);
|
||||||
const text = decoder.decode(value, { stream: true });
|
}
|
||||||
const match = text.match(/data:\s*(\{.*\})/);
|
|
||||||
if (match) {
|
const reader = response.body?.getReader();
|
||||||
yield JSON.parse(match[1])["message"];
|
if (!reader) {
|
||||||
}
|
throw new Error("Response body is not readable");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
/**
|
let buffer = "";
|
||||||
* 非流式对话
|
|
||||||
*/
|
while (true) {
|
||||||
async chat(request: ChatRequest): Promise<ChatResponse> {
|
const { done, value } = await reader.read();
|
||||||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
|
if (done) break;
|
||||||
method: "POST",
|
|
||||||
headers: {
|
buffer += decoder.decode(value, { stream: true });
|
||||||
"Content-Type": "application/json",
|
const lines = buffer.split("\n");
|
||||||
},
|
// 保留最后一行未完整的 JSON
|
||||||
body: JSON.stringify(request),
|
buffer = lines.pop() || "";
|
||||||
});
|
|
||||||
|
for (const line of lines) {
|
||||||
if (!response.ok) {
|
if (line.trim() === "" || line.includes("[DONE]")) continue;
|
||||||
const error = await response.text();
|
const match = line.match(/^data:\s*(.+)$/);
|
||||||
throw new Error(error || `HTTP ${response.status}`);
|
if (match) {
|
||||||
}
|
try {
|
||||||
|
const data = JSON.parse(match[1]);
|
||||||
return response.json();
|
const content = data.choices?.[0]?.delta?.content;
|
||||||
}
|
if (content) {
|
||||||
|
yield content;
|
||||||
/**
|
}
|
||||||
* 停止对话
|
} catch (e) {
|
||||||
*/
|
console.warn("JSON解析错误", e, line);
|
||||||
async stopChat(messageId?: string) {
|
}
|
||||||
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
|
}
|
||||||
method: "POST",
|
}
|
||||||
headers: {
|
}
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
},
|
|
||||||
});
|
/**
|
||||||
}
|
* 非流式对话
|
||||||
|
*/
|
||||||
/**
|
async chat(request: ChatRequest): Promise<ChatResponse> {
|
||||||
* 获取模型列表
|
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
|
||||||
*/
|
method: "POST",
|
||||||
async getModels(): Promise<ModelInfo[]> {
|
headers: {
|
||||||
return [
|
"Content-Type": "application/json",
|
||||||
{
|
},
|
||||||
id: "gpt-4",
|
body: JSON.stringify(request),
|
||||||
name: "GPT-4",
|
});
|
||||||
description: "最强大的模型",
|
|
||||||
maxTokens: 8192,
|
if (!response.ok) {
|
||||||
provider: "OpenAI",
|
const error = await response.text();
|
||||||
},
|
throw new Error(error || `HTTP ${response.status}`);
|
||||||
{
|
}
|
||||||
id: "gpt-3.5-turbo",
|
|
||||||
name: "GPT-3.5 Turbo",
|
return response.json();
|
||||||
description: "快速高效",
|
}
|
||||||
maxTokens: 16384,
|
|
||||||
provider: "OpenAI",
|
/**
|
||||||
},
|
* 停止对话
|
||||||
];
|
*/
|
||||||
}
|
async stopChat(messageId?: string) {
|
||||||
|
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
|
||||||
/**
|
method: "POST",
|
||||||
* 上传文件
|
headers: {
|
||||||
*/
|
"Content-Type": "application/json",
|
||||||
async uploadFile(file: File): Promise<UploadResult> {
|
},
|
||||||
const formData = new FormData();
|
});
|
||||||
formData.append("file", file);
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
|
/**
|
||||||
method: "POST",
|
* 获取模型列表
|
||||||
body: formData,
|
*/
|
||||||
});
|
async getModels(): Promise<ModelInfo[]> {
|
||||||
|
return [
|
||||||
if (!response.ok) {
|
{
|
||||||
throw new Error(`上传失败: HTTP ${response.status}`);
|
id: "qwen-max",
|
||||||
}
|
name: "通义千问 Max",
|
||||||
|
description: "最强大的模型",
|
||||||
return response.json();
|
maxTokens: 8192,
|
||||||
}
|
provider: "Aliyun",
|
||||||
}
|
},
|
||||||
|
{
|
||||||
// 导出单例
|
id: "qwen-plus",
|
||||||
export const chatApi = new ChatApi();
|
name: "通义千问 Plus",
|
||||||
|
description: "能力均衡",
|
||||||
// 导出类用于自定义配置
|
maxTokens: 8192,
|
||||||
export { ChatApi, API_ENDPOINTS };
|
provider: "Aliyun",
|
||||||
|
},
|
||||||
// 导出端点常量(供调试使用)
|
];
|
||||||
// export {API_ENDPOINTS}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
*/
|
||||||
|
async uploadFile(file: File): Promise<UploadResult> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`上传失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const chatApi = new ChatApi();
|
||||||
|
|
||||||
|
// 导出类用于自定义配置
|
||||||
|
export { ChatApi, API_ENDPOINTS };
|
||||||
|
|
||||||
|
// 导出端点常量(供调试使用)
|
||||||
|
// export {API_ENDPOINTS}
|
||||||
|
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
import { generateId } from '@/utils/helpers'
|
|
||||||
|
|
||||||
// 模拟响应数据
|
|
||||||
const mockResponses: Record<string, string> = {
|
|
||||||
default: `你好!我是 AI 智能助手,很高兴为你服务。
|
|
||||||
|
|
||||||
我可以帮助你:
|
|
||||||
- 回答各种问题
|
|
||||||
- 编写和解释代码
|
|
||||||
- 分析数据和文档
|
|
||||||
- 翻译和写作
|
|
||||||
|
|
||||||
请问有什么可以帮助你的?`,
|
|
||||||
|
|
||||||
code: `好的,这是一个 Vue 3 组件示例:
|
|
||||||
|
|
||||||
\`\`\`vue
|
|
||||||
<template>
|
|
||||||
<div class="counter">
|
|
||||||
<h2>计数器: {{ count }}</h2>
|
|
||||||
<button @click="increment">增加</button>
|
|
||||||
<button @click="decrement">减少</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const count = ref(0)
|
|
||||||
|
|
||||||
function increment() {
|
|
||||||
count.value++
|
|
||||||
}
|
|
||||||
|
|
||||||
function decrement() {
|
|
||||||
count.value--
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.counter {
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin: 0 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
这个组件展示了 Vue 3 的几个核心特性:
|
|
||||||
1. **Composition API**: 使用 \`<script setup>\` 语法
|
|
||||||
2. **响应式数据**: 使用 \`ref\` 创建响应式变量
|
|
||||||
3. **事件处理**: 使用 \`@click\` 绑定事件
|
|
||||||
4. **样式隔离**: 使用 \`scoped\` 样式`,
|
|
||||||
|
|
||||||
ml: `**机器学习(Machine Learning)** 是人工智能的一个分支,它使计算机系统能够从数据中学习并改进,而无需进行明确的编程。
|
|
||||||
|
|
||||||
## 主要类型
|
|
||||||
|
|
||||||
### 1. 监督学习
|
|
||||||
- 使用带标签的数据进行训练
|
|
||||||
- 例如:分类、回归
|
|
||||||
|
|
||||||
### 2. 无监督学习
|
|
||||||
- 使用无标签的数据
|
|
||||||
- 例如:聚类、降维
|
|
||||||
|
|
||||||
### 3. 强化学习
|
|
||||||
- 通过与环境交互学习
|
|
||||||
- 例如:游戏AI、机器人控制
|
|
||||||
|
|
||||||
## 常见应用
|
|
||||||
|
|
||||||
| 领域 | 应用示例 |
|
|
||||||
|------|----------|
|
|
||||||
| 图像识别 | 人脸识别、医学影像 |
|
|
||||||
| 自然语言处理 | 翻译、聊天机器人 |
|
|
||||||
| 推荐系统 | 购物推荐、内容推荐 |
|
|
||||||
|
|
||||||
> 💡 机器学习正在改变我们生活的方方面面!`,
|
|
||||||
|
|
||||||
email: `好的,这是一封商务邮件模板:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**主题:关于项目合作事宜**
|
|
||||||
|
|
||||||
尊敬的 [收件人姓名]:
|
|
||||||
|
|
||||||
您好!
|
|
||||||
|
|
||||||
感谢您抽出宝贵时间与我们进行沟通。关于我们之前讨论的项目合作事宜,现将相关内容整理如下:
|
|
||||||
|
|
||||||
**合作要点:**
|
|
||||||
1. 项目范围:[具体描述]
|
|
||||||
2. 时间节点:[预计开始和结束时间]
|
|
||||||
3. 预算范围:[金额区间]
|
|
||||||
|
|
||||||
如有任何问题或需要进一步讨论,请随时与我联系。
|
|
||||||
|
|
||||||
期待您的回复!
|
|
||||||
|
|
||||||
此致
|
|
||||||
敬礼
|
|
||||||
|
|
||||||
[您的姓名]
|
|
||||||
[职位]
|
|
||||||
[联系方式]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
请根据实际情况修改方括号中的内容。需要我帮你调整任何部分吗?`,
|
|
||||||
|
|
||||||
react: `# React 应用性能优化指南
|
|
||||||
|
|
||||||
## 1. 组件优化
|
|
||||||
|
|
||||||
### 使用 React.memo
|
|
||||||
\`\`\`jsx
|
|
||||||
const MyComponent = React.memo(({ data }) => {
|
|
||||||
return <div>{data.name}</div>
|
|
||||||
})
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### 使用 useMemo 和 useCallback
|
|
||||||
\`\`\`javascript
|
|
||||||
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b])
|
|
||||||
const memoizedCallback = useCallback(() => doSomething(a, b), [a, b])
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## 2. 代码分割
|
|
||||||
|
|
||||||
\`\`\`javascript
|
|
||||||
const LazyComponent = React.lazy(() => import('./LazyComponent'))
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<LazyComponent />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## 3. 虚拟列表
|
|
||||||
|
|
||||||
对于长列表,使用 **react-window** 或 **react-virtualized**:
|
|
||||||
|
|
||||||
\`\`\`javascript
|
|
||||||
import { FixedSizeList } from 'react-window'
|
|
||||||
|
|
||||||
<FixedSizeList
|
|
||||||
height={400}
|
|
||||||
itemCount={1000}
|
|
||||||
itemSize={35}
|
|
||||||
>
|
|
||||||
{Row}
|
|
||||||
</FixedSizeList>
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## 4. 性能监控
|
|
||||||
|
|
||||||
使用 React DevTools Profiler 分析组件渲染性能。
|
|
||||||
|
|
||||||
> 🚀 记住:**过早优化是万恶之源**,只在需要时进行优化!`,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据输入内容匹配响应
|
|
||||||
function matchResponse(input: string): string {
|
|
||||||
const lowerInput = input.toLowerCase()
|
|
||||||
|
|
||||||
if (lowerInput.includes('vue') || lowerInput.includes('组件')) {
|
|
||||||
return mockResponses.code
|
|
||||||
}
|
|
||||||
if (lowerInput.includes('机器学习') || lowerInput.includes('ml') || lowerInput.includes('学习')) {
|
|
||||||
return mockResponses.ml
|
|
||||||
}
|
|
||||||
if (lowerInput.includes('邮件') || lowerInput.includes('商务')) {
|
|
||||||
return mockResponses.email
|
|
||||||
}
|
|
||||||
if (lowerInput.includes('react') || lowerInput.includes('性能') || lowerInput.includes('优化')) {
|
|
||||||
return mockResponses.react
|
|
||||||
}
|
|
||||||
|
|
||||||
return mockResponses.default
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流式输出生成器
|
|
||||||
async function* streamText(text: string, signal?: AbortSignal): AsyncGenerator<string> {
|
|
||||||
const chars = text.split('')
|
|
||||||
let buffer = ''
|
|
||||||
|
|
||||||
for (let i = 0; i < chars.length; i++) {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += chars[i]
|
|
||||||
|
|
||||||
const delay = Math.random() * 20 + 5
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay))
|
|
||||||
|
|
||||||
if (buffer.length >= 3 || i === chars.length - 1) {
|
|
||||||
yield buffer
|
|
||||||
buffer = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟 AI 响应接口
|
|
||||||
export interface StreamCallbacks {
|
|
||||||
onStart?: () => void
|
|
||||||
onToken?: (token: string, fullText: string) => void
|
|
||||||
onComplete?: (fullText: string) => void
|
|
||||||
onError?: (error: Error) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function streamAIResponse(
|
|
||||||
userMessage: string,
|
|
||||||
callbacks: StreamCallbacks,
|
|
||||||
signal?: AbortSignal
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
callbacks.onStart?.()
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
|
||||||
|
|
||||||
if (signal?.aborted) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = matchResponse(userMessage)
|
|
||||||
let fullText = ''
|
|
||||||
|
|
||||||
for await (const token of streamText(response, signal)) {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
fullText += token
|
|
||||||
callbacks.onToken?.(token, fullText)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signal?.aborted) {
|
|
||||||
callbacks.onComplete?.(fullText)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.name !== 'AbortError') {
|
|
||||||
callbacks.onError?.(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成推荐选项
|
|
||||||
export function generateSuggestions(): { id: string; text: string }[] {
|
|
||||||
const suggestions = [
|
|
||||||
'继续深入讲解',
|
|
||||||
'给我一个实际例子',
|
|
||||||
'有什么最佳实践吗?',
|
|
||||||
'可以用中文解释吗?',
|
|
||||||
]
|
|
||||||
|
|
||||||
return suggestions.map(text => ({
|
|
||||||
id: generateId(),
|
|
||||||
text,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
@ -1,285 +1,285 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import type {
|
import type {
|
||||||
Conversation,
|
Conversation,
|
||||||
Message,
|
Message,
|
||||||
MessageContent,
|
MessageContent,
|
||||||
ConversationSettings,
|
ConversationSettings,
|
||||||
} from "@/types/chat";
|
} from "@/types/chat";
|
||||||
import { MessageRole } from "@/types/chat";
|
import { MessageRole } from "@/types/chat";
|
||||||
import { generateId, extractTitleFromMessage } from "@/utils/helpers";
|
import { generateId, extractTitleFromMessage } from "@/utils/helpers";
|
||||||
|
|
||||||
export const useChatStore = defineStore("chat", () => {
|
export const useChatStore = defineStore("chat", () => {
|
||||||
// 状态
|
// 状态
|
||||||
const conversations = ref<Conversation[]>([]);
|
const conversations = ref<Conversation[]>([]);
|
||||||
const currentConversationId = ref<string | null>(null);
|
const currentConversationId = ref<string | null>(null);
|
||||||
const isStreaming = ref(false);
|
const isStreaming = ref(false);
|
||||||
const streamController = ref<AbortController | null>(null);
|
const streamController = ref<AbortController | null>(null);
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const currentConversation = computed(() => {
|
const currentConversation = computed(() => {
|
||||||
return (
|
return (
|
||||||
conversations.value.find((c) => c.id === currentConversationId.value) ||
|
conversations.value.find((c) => c.id === currentConversationId.value) ||
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedConversations = computed(() => {
|
const sortedConversations = computed(() => {
|
||||||
return [...conversations.value].sort((a, b) => {
|
return [...conversations.value].sort((a, b) => {
|
||||||
if (a.pinned && !b.pinned) return -1;
|
if (a.pinned && !b.pinned) return -1;
|
||||||
if (!a.pinned && b.pinned) return 1;
|
if (!a.pinned && b.pinned) return 1;
|
||||||
return b.updatedAt - a.updatedAt;
|
return b.updatedAt - a.updatedAt;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const pinnedConversations = computed(() => {
|
const pinnedConversations = computed(() => {
|
||||||
return sortedConversations.value.filter((c) => c.pinned && !c.archived);
|
return sortedConversations.value.filter((c) => c.pinned && !c.archived);
|
||||||
});
|
});
|
||||||
|
|
||||||
const recentConversations = computed(() => {
|
const recentConversations = computed(() => {
|
||||||
return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
|
return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
function createConversation(): string {
|
function createConversation(): string {
|
||||||
const newConversation: Conversation = {
|
const newConversation: Conversation = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
title: "新对话",
|
title: "新对话",
|
||||||
messages: [],
|
messages: [],
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
settings: undefined,
|
settings: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
conversations.value.unshift(newConversation);
|
conversations.value.unshift(newConversation);
|
||||||
currentConversationId.value = newConversation.id;
|
currentConversationId.value = newConversation.id;
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
|
|
||||||
return newConversation.id;
|
return newConversation.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteConversation(id: string) {
|
function deleteConversation(id: string) {
|
||||||
const index = conversations.value.findIndex((c) => c.id === id);
|
const index = conversations.value.findIndex((c) => c.id === id);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
conversations.value.splice(index, 1);
|
conversations.value.splice(index, 1);
|
||||||
|
|
||||||
if (currentConversationId.value === id) {
|
if (currentConversationId.value === id) {
|
||||||
currentConversationId.value = conversations.value[0]?.id || null;
|
currentConversationId.value = conversations.value[0]?.id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectConversation(id: string) {
|
function selectConversation(id: string) {
|
||||||
currentConversationId.value = id;
|
currentConversationId.value = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePinConversation(id: string) {
|
function togglePinConversation(id: string) {
|
||||||
const conversation = conversations.value.find((c) => c.id === id);
|
const conversation = conversations.value.find((c) => c.id === id);
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
conversation.pinned = !conversation.pinned;
|
conversation.pinned = !conversation.pinned;
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renameConversation(id: string, newTitle: string) {
|
function renameConversation(id: string, newTitle: string) {
|
||||||
const conversation = conversations.value.find((c) => c.id === id);
|
const conversation = conversations.value.find((c) => c.id === id);
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
conversation.title = newTitle;
|
conversation.title = newTitle;
|
||||||
conversation.updatedAt = Date.now();
|
conversation.updatedAt = Date.now();
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateConversationSettings(
|
function updateConversationSettings(
|
||||||
id: string,
|
id: string,
|
||||||
convSettings: ConversationSettings,
|
convSettings: ConversationSettings,
|
||||||
) {
|
) {
|
||||||
const conversation = conversations.value.find((c) => c.id === id);
|
const conversation = conversations.value.find((c) => c.id === id);
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
conversation.settings = { ...conversation.settings, ...convSettings };
|
conversation.settings = { ...conversation.settings, ...convSettings };
|
||||||
conversation.updatedAt = Date.now();
|
conversation.updatedAt = Date.now();
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMessage(
|
function addMessage(
|
||||||
role: MessageRole,
|
role: MessageRole,
|
||||||
content: MessageContent,
|
content: MessageContent,
|
||||||
conversationId?: string,
|
conversationId?: string,
|
||||||
): Message {
|
): Message {
|
||||||
const targetId = conversationId || currentConversationId.value;
|
const targetId = conversationId || currentConversationId.value;
|
||||||
|
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
createConversation();
|
createConversation();
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversation = conversations.value.find(
|
const conversation = conversations.value.find(
|
||||||
(c) => c.id === (targetId || currentConversationId.value),
|
(c) => c.id === (targetId || currentConversationId.value),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
throw new Error("Conversation not found");
|
throw new Error("Conversation not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const message: any = {
|
const message: any = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role,
|
role,
|
||||||
content,
|
content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
conversation.messages.push(message);
|
conversation.messages.push(message);
|
||||||
conversation.updatedAt = Date.now();
|
conversation.updatedAt = Date.now();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
role === MessageRole.USER &&
|
role === MessageRole.USER &&
|
||||||
conversation.messages.length === 1 &&
|
conversation.messages.length === 1 &&
|
||||||
content.text
|
content.text
|
||||||
) {
|
) {
|
||||||
conversation.title = extractTitleFromMessage(content.text);
|
conversation.title = extractTitleFromMessage(content.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMessage(messageId: string, updates: Partial<Message>) {
|
function updateMessage(messageId: string, updates: Partial<Message>) {
|
||||||
const conversation = currentConversation.value;
|
const conversation = currentConversation.value;
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
|
|
||||||
const message = conversation.messages.find((m) => m.id === messageId);
|
const message = conversation.messages.find((m) => m.id === messageId);
|
||||||
if (message) {
|
if (message) {
|
||||||
Object.assign(message, updates);
|
Object.assign(message, updates);
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMessageContent(messageId: string, text: string) {
|
function updateMessageContent(messageId: string, text: string) {
|
||||||
const conversation = currentConversation.value;
|
const conversation = currentConversation.value;
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
|
|
||||||
const message = conversation.messages.find((m) => m.id === messageId);
|
const message = conversation.messages.find((m) => m.id === messageId);
|
||||||
if (message) {
|
if (message) {
|
||||||
message.content.text = text;
|
message.content.text = text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMessageFeedback(
|
function setMessageFeedback(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
feedback: "like" | "dislike" | null,
|
feedback: "like" | "dislike" | null,
|
||||||
) {
|
) {
|
||||||
const conversation = currentConversation.value;
|
const conversation = currentConversation.value;
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
|
|
||||||
const message = conversation.messages.find((m) => m.id === messageId);
|
const message = conversation.messages.find((m) => m.id === messageId);
|
||||||
if (message) {
|
if (message) {
|
||||||
message.feedback = {
|
message.feedback = {
|
||||||
liked: feedback === "like",
|
liked: feedback === "like",
|
||||||
disliked: feedback === "dislike",
|
disliked: feedback === "dislike",
|
||||||
copied: message.feedback?.copied,
|
copied: message.feedback?.copied,
|
||||||
};
|
};
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMessageCopied(messageId: string) {
|
function setMessageCopied(messageId: string) {
|
||||||
const conversation = currentConversation.value;
|
const conversation = currentConversation.value;
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
|
|
||||||
const message = conversation.messages.find((m) => m.id === messageId);
|
const message = conversation.messages.find((m) => m.id === messageId);
|
||||||
if (message) {
|
if (message) {
|
||||||
message.feedback = {
|
message.feedback = {
|
||||||
...message.feedback,
|
...message.feedback,
|
||||||
copied: true,
|
copied: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startStreaming() {
|
function startStreaming() {
|
||||||
isStreaming.value = true;
|
isStreaming.value = true;
|
||||||
streamController.value = new AbortController();
|
streamController.value = new AbortController();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopStreaming() {
|
function stopStreaming() {
|
||||||
isStreaming.value = false;
|
isStreaming.value = false;
|
||||||
if (streamController.value) {
|
if (streamController.value) {
|
||||||
streamController.value.abort();
|
streamController.value.abort();
|
||||||
streamController.value = null;
|
streamController.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearConversation(id: string) {
|
function clearConversation(id: string) {
|
||||||
const conversation = conversations.value.find((c) => c.id === id);
|
const conversation = conversations.value.find((c) => c.id === id);
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
conversation.messages = [];
|
conversation.messages = [];
|
||||||
conversation.updatedAt = Date.now();
|
conversation.updatedAt = Date.now();
|
||||||
saveToStorage();
|
saveToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveToStorage() {
|
function saveToStorage() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"chat-conversations",
|
"chat-conversations",
|
||||||
JSON.stringify(conversations.value),
|
JSON.stringify(conversations.value),
|
||||||
);
|
);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"chat-current-id",
|
"chat-current-id",
|
||||||
currentConversationId.value || "",
|
currentConversationId.value || "",
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save to storage:", e);
|
console.error("Failed to save to storage:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromStorage() {
|
function loadFromStorage() {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("chat-conversations");
|
const stored = localStorage.getItem("chat-conversations");
|
||||||
if (stored) {
|
if (stored) {
|
||||||
conversations.value = JSON.parse(stored);
|
conversations.value = JSON.parse(stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedId = localStorage.getItem("chat-current-id");
|
const storedId = localStorage.getItem("chat-current-id");
|
||||||
if (storedId && conversations.value.find((c) => c.id === storedId)) {
|
if (storedId && conversations.value.find((c) => c.id === storedId)) {
|
||||||
currentConversationId.value = storedId;
|
currentConversationId.value = storedId;
|
||||||
} else if (conversations.value.length > 0) {
|
} else if (conversations.value.length > 0) {
|
||||||
currentConversationId.value = conversations.value[0].id;
|
currentConversationId.value = conversations.value[0].id;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load from storage:", e);
|
console.error("Failed to load from storage:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFromStorage();
|
loadFromStorage();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
conversations,
|
conversations,
|
||||||
currentConversationId,
|
currentConversationId,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
streamController,
|
streamController,
|
||||||
currentConversation,
|
currentConversation,
|
||||||
sortedConversations,
|
sortedConversations,
|
||||||
pinnedConversations,
|
pinnedConversations,
|
||||||
recentConversations,
|
recentConversations,
|
||||||
createConversation,
|
createConversation,
|
||||||
deleteConversation,
|
deleteConversation,
|
||||||
selectConversation,
|
selectConversation,
|
||||||
togglePinConversation,
|
togglePinConversation,
|
||||||
renameConversation,
|
renameConversation,
|
||||||
updateConversationSettings,
|
updateConversationSettings,
|
||||||
addMessage,
|
addMessage,
|
||||||
updateMessage,
|
updateMessage,
|
||||||
updateMessageContent,
|
updateMessageContent,
|
||||||
setMessageFeedback,
|
setMessageFeedback,
|
||||||
setMessageCopied,
|
setMessageCopied,
|
||||||
startStreaming,
|
startStreaming,
|
||||||
stopStreaming,
|
stopStreaming,
|
||||||
clearConversation,
|
clearConversation,
|
||||||
loadFromStorage,
|
loadFromStorage,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,291 +1,284 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { AppSettings, AIModel } from '@/types/chat'
|
import type { AppSettings, AIModel } from '@/types/chat'
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
// 默认设置
|
// 默认设置
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
// 外观设置
|
// 外观设置
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
fontSize: 'medium',
|
fontSize: 'medium',
|
||||||
|
|
||||||
// 对话设置
|
// 对话设置
|
||||||
sendOnEnter: false,
|
sendOnEnter: false,
|
||||||
showTimestamp: true,
|
showTimestamp: true,
|
||||||
compactMode: false,
|
compactMode: false,
|
||||||
|
|
||||||
// AI 默认设置
|
// AI 默认设置
|
||||||
defaultModel: 'gpt-4',
|
defaultModel: 'qwen-plus',
|
||||||
defaultTemperature: 0.7,
|
defaultTemperature: 0.7,
|
||||||
defaultMaxTokens: 4096,
|
defaultMaxTokens: 4096,
|
||||||
defaultSystemPrompt: '你是一个有帮助的 AI 助手。',
|
defaultSystemPrompt: '你是一个有帮助的 AI 助手。',
|
||||||
|
|
||||||
// 功能设置
|
// 功能设置
|
||||||
enableSound: true,
|
enableSound: true,
|
||||||
enableNotification: true,
|
enableNotification: true,
|
||||||
autoSaveInterval: 30,
|
autoSaveInterval: 30,
|
||||||
|
|
||||||
// 隐私设置
|
// 隐私设置
|
||||||
saveHistory: true,
|
saveHistory: true,
|
||||||
shareAnalytics: false,
|
shareAnalytics: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 可用的 AI 模型
|
// 可用的 AI 模型
|
||||||
const availableModels: AIModel[] = [
|
const availableModels: AIModel[] = [
|
||||||
{
|
{
|
||||||
id: 'gpt-4',
|
id: 'qwen-max',
|
||||||
name: 'GPT-4',
|
name: '通义千问 Max',
|
||||||
description: '最强大的模型,适合复杂任务',
|
description: '最强大的模型,适合复杂任务',
|
||||||
maxTokens: 8192,
|
maxTokens: 8192,
|
||||||
provider: 'OpenAI',
|
provider: 'Aliyun',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gpt-4-turbo',
|
id: 'qwen-plus',
|
||||||
name: 'GPT-4 Turbo',
|
name: '通义千问 Plus',
|
||||||
description: '更快的响应速度,128K 上下文',
|
description: '能力均衡,更快的响应速度',
|
||||||
maxTokens: 128000,
|
maxTokens: 8192,
|
||||||
provider: 'OpenAI',
|
provider: 'Aliyun',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gpt-3.5-turbo',
|
id: 'qwen-turbo',
|
||||||
name: 'GPT-3.5 Turbo',
|
name: '通义千问 Turbo',
|
||||||
description: '快速高效,适合日常对话',
|
description: '快速高效,适合日常对话',
|
||||||
maxTokens: 16384,
|
maxTokens: 8192,
|
||||||
provider: 'OpenAI',
|
provider: 'Aliyun',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'claude-3-opus',
|
id: 'qwen-vl-max',
|
||||||
name: 'Claude 3 Opus',
|
name: '通义千问 VL-Max',
|
||||||
description: '优秀的长文本处理能力',
|
description: '强大的视觉理解模型',
|
||||||
maxTokens: 200000,
|
maxTokens: 8192,
|
||||||
provider: 'Anthropic',
|
provider: 'Aliyun',
|
||||||
},
|
},
|
||||||
{
|
]
|
||||||
id: 'claude-3-sonnet',
|
|
||||||
name: 'Claude 3 Sonnet',
|
// 状态
|
||||||
description: '平衡性能与成本',
|
const settings = ref<AppSettings>({ ...defaultSettings })
|
||||||
maxTokens: 200000,
|
const sidebarCollapsed = ref(false)
|
||||||
provider: 'Anthropic',
|
const sidebarWidth = ref(280)
|
||||||
},
|
const showShortcutsModal = ref(false)
|
||||||
]
|
const showSearchModal = ref(false)
|
||||||
|
const showSettingsModal = ref(false)
|
||||||
// 状态
|
const showConversationSettingsModal = ref(false)
|
||||||
const settings = ref<AppSettings>({ ...defaultSettings })
|
|
||||||
const sidebarCollapsed = ref(false)
|
// 主题相关
|
||||||
const sidebarWidth = ref(280)
|
function applyTheme(theme: AppSettings['theme']) {
|
||||||
const showShortcutsModal = ref(false)
|
const root = document.documentElement
|
||||||
const showSearchModal = ref(false)
|
|
||||||
const showSettingsModal = ref(false)
|
if (theme === 'system') {
|
||||||
const showConversationSettingsModal = ref(false)
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
root.classList.toggle('dark', prefersDark)
|
||||||
// 主题相关
|
} else {
|
||||||
function applyTheme(theme: AppSettings['theme']) {
|
root.classList.toggle('dark', theme === 'dark')
|
||||||
const root = document.documentElement
|
}
|
||||||
|
}
|
||||||
if (theme === 'system') {
|
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
function toggleTheme() {
|
||||||
root.classList.toggle('dark', prefersDark)
|
const themes: AppSettings['theme'][] = ['light', 'dark', 'system']
|
||||||
} else {
|
const currentIndex = themes.indexOf(settings.value.theme)
|
||||||
root.classList.toggle('dark', theme === 'dark')
|
settings.value.theme = themes[(currentIndex + 1) % themes.length]
|
||||||
}
|
applyTheme(settings.value.theme)
|
||||||
}
|
saveToStorage()
|
||||||
|
}
|
||||||
function toggleTheme() {
|
|
||||||
const themes: AppSettings['theme'][] = ['light', 'dark', 'system']
|
function setTheme(theme: AppSettings['theme']) {
|
||||||
const currentIndex = themes.indexOf(settings.value.theme)
|
settings.value.theme = theme
|
||||||
settings.value.theme = themes[(currentIndex + 1) % themes.length]
|
applyTheme(theme)
|
||||||
applyTheme(settings.value.theme)
|
saveToStorage()
|
||||||
saveToStorage()
|
}
|
||||||
}
|
|
||||||
|
// 字体大小
|
||||||
function setTheme(theme: AppSettings['theme']) {
|
function applyFontSize(size: AppSettings['fontSize']) {
|
||||||
settings.value.theme = theme
|
const root = document.documentElement
|
||||||
applyTheme(theme)
|
const sizeMap = {
|
||||||
saveToStorage()
|
small: '14px',
|
||||||
}
|
medium: '16px',
|
||||||
|
large: '18px',
|
||||||
// 字体大小
|
}
|
||||||
function applyFontSize(size: AppSettings['fontSize']) {
|
root.style.setProperty('--base-font-size', sizeMap[size])
|
||||||
const root = document.documentElement
|
}
|
||||||
const sizeMap = {
|
|
||||||
small: '14px',
|
function setFontSize(size: AppSettings['fontSize']) {
|
||||||
medium: '16px',
|
settings.value.fontSize = size
|
||||||
large: '18px',
|
applyFontSize(size)
|
||||||
}
|
saveToStorage()
|
||||||
root.style.setProperty('--base-font-size', sizeMap[size])
|
}
|
||||||
}
|
|
||||||
|
// 侧边栏
|
||||||
function setFontSize(size: AppSettings['fontSize']) {
|
function toggleSidebar() {
|
||||||
settings.value.fontSize = size
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
applyFontSize(size)
|
saveToStorage()
|
||||||
saveToStorage()
|
}
|
||||||
}
|
|
||||||
|
function setSidebarWidth(width: number) {
|
||||||
// 侧边栏
|
sidebarWidth.value = Math.max(200, Math.min(400, width))
|
||||||
function toggleSidebar() {
|
saveToStorage()
|
||||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
}
|
||||||
saveToStorage()
|
|
||||||
}
|
// 模态框
|
||||||
|
function openShortcutsModal() {
|
||||||
function setSidebarWidth(width: number) {
|
showShortcutsModal.value = true
|
||||||
sidebarWidth.value = Math.max(200, Math.min(400, width))
|
}
|
||||||
saveToStorage()
|
|
||||||
}
|
function closeShortcutsModal() {
|
||||||
|
showShortcutsModal.value = false
|
||||||
// 模态框
|
}
|
||||||
function openShortcutsModal() {
|
|
||||||
showShortcutsModal.value = true
|
function openSearchModal() {
|
||||||
}
|
showSearchModal.value = true
|
||||||
|
}
|
||||||
function closeShortcutsModal() {
|
|
||||||
showShortcutsModal.value = false
|
function closeSearchModal() {
|
||||||
}
|
showSearchModal.value = false
|
||||||
|
}
|
||||||
function openSearchModal() {
|
|
||||||
showSearchModal.value = true
|
function openSettingsModal() {
|
||||||
}
|
showSettingsModal.value = true
|
||||||
|
}
|
||||||
function closeSearchModal() {
|
|
||||||
showSearchModal.value = false
|
function closeSettingsModal() {
|
||||||
}
|
showSettingsModal.value = false
|
||||||
|
}
|
||||||
function openSettingsModal() {
|
|
||||||
showSettingsModal.value = true
|
function openConversationSettingsModal() {
|
||||||
}
|
showConversationSettingsModal.value = true
|
||||||
|
}
|
||||||
function closeSettingsModal() {
|
|
||||||
showSettingsModal.value = false
|
function closeConversationSettingsModal() {
|
||||||
}
|
showConversationSettingsModal.value = false
|
||||||
|
}
|
||||||
function openConversationSettingsModal() {
|
|
||||||
showConversationSettingsModal.value = true
|
// 更新设置
|
||||||
}
|
function updateSettings(updates: Partial<AppSettings>) {
|
||||||
|
Object.assign(settings.value, updates)
|
||||||
function closeConversationSettingsModal() {
|
|
||||||
showConversationSettingsModal.value = false
|
if (updates.theme) {
|
||||||
}
|
applyTheme(updates.theme)
|
||||||
|
}
|
||||||
// 更新设置
|
|
||||||
function updateSettings(updates: Partial<AppSettings>) {
|
if (updates.fontSize) {
|
||||||
Object.assign(settings.value, updates)
|
applyFontSize(updates.fontSize)
|
||||||
|
}
|
||||||
if (updates.theme) {
|
|
||||||
applyTheme(updates.theme)
|
saveToStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updates.fontSize) {
|
// 重置设置
|
||||||
applyFontSize(updates.fontSize)
|
function resetSettings() {
|
||||||
}
|
settings.value = { ...defaultSettings }
|
||||||
|
applyTheme(settings.value.theme)
|
||||||
saveToStorage()
|
applyFontSize(settings.value.fontSize)
|
||||||
}
|
saveToStorage()
|
||||||
|
}
|
||||||
// 重置设置
|
|
||||||
function resetSettings() {
|
// 导出设置
|
||||||
settings.value = { ...defaultSettings }
|
function exportSettings(): string {
|
||||||
applyTheme(settings.value.theme)
|
return JSON.stringify(settings.value, null, 2)
|
||||||
applyFontSize(settings.value.fontSize)
|
}
|
||||||
saveToStorage()
|
|
||||||
}
|
// 导入设置
|
||||||
|
function importSettings(json: string): boolean {
|
||||||
// 导出设置
|
try {
|
||||||
function exportSettings(): string {
|
const imported = JSON.parse(json)
|
||||||
return JSON.stringify(settings.value, null, 2)
|
settings.value = { ...defaultSettings, ...imported }
|
||||||
}
|
applyTheme(settings.value.theme)
|
||||||
|
applyFontSize(settings.value.fontSize)
|
||||||
// 导入设置
|
saveToStorage()
|
||||||
function importSettings(json: string): boolean {
|
return true
|
||||||
try {
|
} catch {
|
||||||
const imported = JSON.parse(json)
|
return false
|
||||||
settings.value = { ...defaultSettings, ...imported }
|
}
|
||||||
applyTheme(settings.value.theme)
|
}
|
||||||
applyFontSize(settings.value.fontSize)
|
|
||||||
saveToStorage()
|
// 存储
|
||||||
return true
|
function saveToStorage() {
|
||||||
} catch {
|
try {
|
||||||
return false
|
localStorage.setItem('chat-settings', JSON.stringify(settings.value))
|
||||||
}
|
localStorage.setItem('chat-sidebar-collapsed', JSON.stringify(sidebarCollapsed.value))
|
||||||
}
|
localStorage.setItem('chat-sidebar-width', JSON.stringify(sidebarWidth.value))
|
||||||
|
} catch (e) {
|
||||||
// 存储
|
console.error('Failed to save settings:', e)
|
||||||
function saveToStorage() {
|
}
|
||||||
try {
|
}
|
||||||
localStorage.setItem('chat-settings', JSON.stringify(settings.value))
|
|
||||||
localStorage.setItem('chat-sidebar-collapsed', JSON.stringify(sidebarCollapsed.value))
|
function loadFromStorage() {
|
||||||
localStorage.setItem('chat-sidebar-width', JSON.stringify(sidebarWidth.value))
|
try {
|
||||||
} catch (e) {
|
const stored = localStorage.getItem('chat-settings')
|
||||||
console.error('Failed to save settings:', e)
|
if (stored) {
|
||||||
}
|
settings.value = { ...defaultSettings, ...JSON.parse(stored) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromStorage() {
|
const collapsedStored = localStorage.getItem('chat-sidebar-collapsed')
|
||||||
try {
|
if (collapsedStored) {
|
||||||
const stored = localStorage.getItem('chat-settings')
|
sidebarCollapsed.value = JSON.parse(collapsedStored)
|
||||||
if (stored) {
|
}
|
||||||
settings.value = { ...defaultSettings, ...JSON.parse(stored) }
|
|
||||||
}
|
const widthStored = localStorage.getItem('chat-sidebar-width')
|
||||||
|
if (widthStored) {
|
||||||
const collapsedStored = localStorage.getItem('chat-sidebar-collapsed')
|
sidebarWidth.value = JSON.parse(widthStored)
|
||||||
if (collapsedStored) {
|
}
|
||||||
sidebarCollapsed.value = JSON.parse(collapsedStored)
|
|
||||||
}
|
// 应用主题和字体
|
||||||
|
applyTheme(settings.value.theme)
|
||||||
const widthStored = localStorage.getItem('chat-sidebar-width')
|
applyFontSize(settings.value.fontSize)
|
||||||
if (widthStored) {
|
} catch (e) {
|
||||||
sidebarWidth.value = JSON.parse(widthStored)
|
console.error('Failed to load settings:', e)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// 应用主题和字体
|
|
||||||
applyTheme(settings.value.theme)
|
// 监听系统主题变化
|
||||||
applyFontSize(settings.value.fontSize)
|
if (typeof window !== 'undefined') {
|
||||||
} catch (e) {
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
console.error('Failed to load settings:', e)
|
mediaQuery.addEventListener('change', () => {
|
||||||
}
|
if (settings.value.theme === 'system') {
|
||||||
}
|
applyTheme('system')
|
||||||
|
}
|
||||||
// 监听系统主题变化
|
})
|
||||||
if (typeof window !== 'undefined') {
|
}
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
|
||||||
mediaQuery.addEventListener('change', () => {
|
// 初始化
|
||||||
if (settings.value.theme === 'system') {
|
loadFromStorage()
|
||||||
applyTheme('system')
|
|
||||||
}
|
return {
|
||||||
})
|
// 状态
|
||||||
}
|
settings,
|
||||||
|
sidebarCollapsed,
|
||||||
// 初始化
|
sidebarWidth,
|
||||||
loadFromStorage()
|
showShortcutsModal,
|
||||||
|
showSearchModal,
|
||||||
return {
|
showSettingsModal,
|
||||||
// 状态
|
showConversationSettingsModal,
|
||||||
settings,
|
availableModels,
|
||||||
sidebarCollapsed,
|
|
||||||
sidebarWidth,
|
// 方法
|
||||||
showShortcutsModal,
|
toggleTheme,
|
||||||
showSearchModal,
|
setTheme,
|
||||||
showSettingsModal,
|
setFontSize,
|
||||||
showConversationSettingsModal,
|
toggleSidebar,
|
||||||
availableModels,
|
setSidebarWidth,
|
||||||
|
openShortcutsModal,
|
||||||
// 方法
|
closeShortcutsModal,
|
||||||
toggleTheme,
|
openSearchModal,
|
||||||
setTheme,
|
closeSearchModal,
|
||||||
setFontSize,
|
openSettingsModal,
|
||||||
toggleSidebar,
|
closeSettingsModal,
|
||||||
setSidebarWidth,
|
openConversationSettingsModal,
|
||||||
openShortcutsModal,
|
closeConversationSettingsModal,
|
||||||
closeShortcutsModal,
|
updateSettings,
|
||||||
openSearchModal,
|
resetSettings,
|
||||||
closeSearchModal,
|
exportSettings,
|
||||||
openSettingsModal,
|
importSettings,
|
||||||
closeSettingsModal,
|
loadFromStorage,
|
||||||
openConversationSettingsModal,
|
}
|
||||||
closeConversationSettingsModal,
|
|
||||||
updateSettings,
|
|
||||||
resetSettings,
|
|
||||||
exportSettings,
|
|
||||||
importSettings,
|
|
||||||
loadFromStorage,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
158
src/style.css
158
src/style.css
|
|
@ -1,79 +1,79 @@
|
||||||
:root {
|
:root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
color: rgba(255, 255, 255, 0.87);
|
||||||
background-color: #242424;
|
background-color: #242424;
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #646cff;
|
color: #646cff;
|
||||||
text-decoration: inherit;
|
text-decoration: inherit;
|
||||||
}
|
}
|
||||||
a:hover {
|
a:hover {
|
||||||
color: #535bf2;
|
color: #535bf2;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 3.2em;
|
font-size: 3.2em;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
padding: 0.6em 1.2em;
|
padding: 0.6em 1.2em;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background-color: #1a1a1a;
|
background-color: #1a1a1a;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.25s;
|
transition: border-color 0.25s;
|
||||||
}
|
}
|
||||||
button:hover {
|
button:hover {
|
||||||
border-color: #646cff;
|
border-color: #646cff;
|
||||||
}
|
}
|
||||||
button:focus,
|
button:focus,
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root {
|
:root {
|
||||||
color: #213547;
|
color: #213547;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
}
|
}
|
||||||
a:hover {
|
a:hover {
|
||||||
color: #747bff;
|
color: #747bff;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,78 @@
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
// 自定义滚动条
|
// 自定义滚动条
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(155, 155, 155, 0.5);
|
background: rgba(155, 155, 155, 0.5);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(155, 155, 155, 0.7);
|
background: rgba(155, 155, 155, 0.7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暗色模式滚动条
|
// 暗色模式滚动条
|
||||||
.dark {
|
.dark {
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(100, 100, 100, 0.5);
|
background: rgba(100, 100, 100, 0.5);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(100, 100, 100, 0.7);
|
background: rgba(100, 100, 100, 0.7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局变量
|
// 全局变量
|
||||||
:root {
|
:root {
|
||||||
--chat-sidebar-width: 280px;
|
--chat-sidebar-width: 280px;
|
||||||
--chat-input-height: 140px;
|
--chat-input-height: 140px;
|
||||||
--header-height: 60px;
|
--header-height: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基础样式重置
|
// 基础样式重置
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过渡动画
|
// 过渡动画
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter-from,
|
.fade-enter-from,
|
||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-enter-active,
|
.slide-enter-active,
|
||||||
.slide-leave-active {
|
.slide-leave-active {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-enter-from {
|
.slide-enter-from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-20px);
|
transform: translateX(-20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-leave-to {
|
.slide-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-20px);
|
transform: translateX(-20px);
|
||||||
}
|
}
|
||||||
|
|
@ -1,146 +1,146 @@
|
||||||
// 消息类型枚举
|
// 消息类型枚举
|
||||||
export enum MessageType {
|
export enum MessageType {
|
||||||
TEXT = "text",
|
TEXT = "text",
|
||||||
IMAGE = "image",
|
IMAGE = "image",
|
||||||
VIDEO = "video",
|
VIDEO = "video",
|
||||||
MULTI_VIDEO = "multi_video",
|
MULTI_VIDEO = "multi_video",
|
||||||
FILE = "file",
|
FILE = "file",
|
||||||
CODE = "code",
|
CODE = "code",
|
||||||
SUGGESTION = "suggestion",
|
SUGGESTION = "suggestion",
|
||||||
THINKING = "thinking",
|
THINKING = "thinking",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息角色
|
// 消息角色
|
||||||
export enum MessageRole {
|
export enum MessageRole {
|
||||||
USER = "user",
|
USER = "user",
|
||||||
ASSISTANT = "assistant",
|
ASSISTANT = "assistant",
|
||||||
SYSTEM = "system",
|
SYSTEM = "system",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 附件类型
|
// 附件类型
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: "image" | "file" | "video";
|
type: "image" | "file" | "video";
|
||||||
url: string;
|
url: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 推荐选项
|
// 推荐选项
|
||||||
export interface Suggestion {
|
export interface Suggestion {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频信息
|
// 视频信息
|
||||||
export interface VideoInfo {
|
export interface VideoInfo {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息内容
|
// 消息内容
|
||||||
export interface MessageContent {
|
export interface MessageContent {
|
||||||
type: MessageType;
|
type: MessageType;
|
||||||
text?: string;
|
text?: string;
|
||||||
images?: Attachment[];
|
images?: Attachment[];
|
||||||
videos?: VideoInfo[];
|
videos?: VideoInfo[];
|
||||||
files?: Attachment[];
|
files?: Attachment[];
|
||||||
suggestions?: Suggestion[];
|
suggestions?: Suggestion[];
|
||||||
codeLanguage?: string;
|
codeLanguage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息反馈
|
// 消息反馈
|
||||||
export interface MessageFeedback {
|
export interface MessageFeedback {
|
||||||
liked?: boolean;
|
liked?: boolean;
|
||||||
disliked?: boolean;
|
disliked?: boolean;
|
||||||
copied?: boolean;
|
copied?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单条消息
|
// 单条消息
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
role: MessageRole;
|
role: MessageRole;
|
||||||
content: MessageContent;
|
content: MessageContent;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
feedback?: MessageFeedback;
|
feedback?: MessageFeedback;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
isEnd?: boolean;
|
isEnd?: boolean;
|
||||||
isBreak?: boolean;
|
isBreak?: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对话设置
|
// 对话设置
|
||||||
export interface ConversationSettings {
|
export interface ConversationSettings {
|
||||||
model: string;
|
model: string;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
enableMemory: boolean;
|
enableMemory: boolean;
|
||||||
memoryLength: number;
|
memoryLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对话
|
// 对话
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
settings?: ConversationSettings;
|
settings?: ConversationSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 输入框状态
|
// 输入框状态
|
||||||
export interface InputState {
|
export interface InputState {
|
||||||
text: string;
|
text: string;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
isDeepSearch: boolean;
|
isDeepSearch: boolean;
|
||||||
isWebSearch: boolean;
|
isWebSearch: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用设置
|
// 应用设置
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
// 外观设置
|
// 外观设置
|
||||||
theme: "light" | "dark" | "system";
|
theme: "light" | "dark" | "system";
|
||||||
language: string;
|
language: string;
|
||||||
fontSize: "small" | "medium" | "large";
|
fontSize: "small" | "medium" | "large";
|
||||||
|
|
||||||
// 对话设置
|
// 对话设置
|
||||||
sendOnEnter: boolean;
|
sendOnEnter: boolean;
|
||||||
showTimestamp: boolean;
|
showTimestamp: boolean;
|
||||||
compactMode: boolean;
|
compactMode: boolean;
|
||||||
|
|
||||||
// AI 默认设置
|
// AI 默认设置
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
defaultTemperature: number;
|
defaultTemperature: number;
|
||||||
defaultMaxTokens: number;
|
defaultMaxTokens: number;
|
||||||
defaultSystemPrompt: string;
|
defaultSystemPrompt: string;
|
||||||
|
|
||||||
// 功能设置
|
// 功能设置
|
||||||
enableSound: boolean;
|
enableSound: boolean;
|
||||||
enableNotification: boolean;
|
enableNotification: boolean;
|
||||||
autoSaveInterval: number;
|
autoSaveInterval: number;
|
||||||
|
|
||||||
// 隐私设置
|
// 隐私设置
|
||||||
saveHistory: boolean;
|
saveHistory: boolean;
|
||||||
shareAnalytics: boolean;
|
shareAnalytics: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI 模型配置
|
// AI 模型配置
|
||||||
export interface AIModel {
|
export interface AIModel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
provider: string;
|
provider: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,148 +1,148 @@
|
||||||
// 删除未使用的 nanoid 导入,使用自定义实现
|
// 删除未使用的 nanoid 导入,使用自定义实现
|
||||||
|
|
||||||
// 生成唯一ID
|
// 生成唯一ID
|
||||||
export function generateId(): string {
|
export function generateId(): string {
|
||||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化时间戳
|
// 格式化时间戳
|
||||||
export function formatTimestamp(timestamp: number): string {
|
export function formatTimestamp(timestamp: number): string {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now.getTime() - date.getTime();
|
const diff = now.getTime() - date.getTime();
|
||||||
|
|
||||||
if (diff < 60 * 1000) {
|
if (diff < 60 * 1000) {
|
||||||
return "刚刚";
|
return "刚刚";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diff < 60 * 60 * 1000) {
|
if (diff < 60 * 60 * 1000) {
|
||||||
const minutes = Math.floor(diff / (60 * 1000));
|
const minutes = Math.floor(diff / (60 * 1000));
|
||||||
return `${minutes}分钟前`;
|
return `${minutes}分钟前`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diff < 24 * 60 * 60 * 1000) {
|
if (diff < 24 * 60 * 60 * 1000) {
|
||||||
const hours = Math.floor(diff / (60 * 60 * 1000));
|
const hours = Math.floor(diff / (60 * 60 * 1000));
|
||||||
return `${hours}小时前`;
|
return `${hours}小时前`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (date.getFullYear() === now.getFullYear()) {
|
if (date.getFullYear() === now.getFullYear()) {
|
||||||
return `${date.getMonth() + 1}月${date.getDate()}日 ${padZero(date.getHours())}:${padZero(date.getMinutes())}`;
|
return `${date.getMonth() + 1}月${date.getDate()}日 ${padZero(date.getHours())}:${padZero(date.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
|
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function padZero(num: number): string {
|
function padZero(num: number): string {
|
||||||
return num < 10 ? `0${num}` : `${num}`;
|
return num < 10 ? `0${num}` : `${num}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return "0 B";
|
if (bytes === 0) return "0 B";
|
||||||
|
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function truncateText(text: string, maxLength: number): string {
|
export function truncateText(text: string, maxLength: number): string {
|
||||||
if (text.length <= maxLength) return text;
|
if (text.length <= maxLength) return text;
|
||||||
return text.slice(0, maxLength) + "...";
|
return text.slice(0, maxLength) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
const textarea = document.createElement("textarea");
|
const textarea = document.createElement("textarea");
|
||||||
textarea.value = text;
|
textarea.value = text;
|
||||||
textarea.style.position = "fixed";
|
textarea.style.position = "fixed";
|
||||||
textarea.style.opacity = "0";
|
textarea.style.opacity = "0";
|
||||||
document.body.appendChild(textarea);
|
document.body.appendChild(textarea);
|
||||||
textarea.select();
|
textarea.select();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
document.body.removeChild(textarea);
|
document.body.removeChild(textarea);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractTitleFromMessage(message: string): string {
|
export function extractTitleFromMessage(message: string): string {
|
||||||
const firstLine = message.split("\n")[0].trim();
|
const firstLine = message.split("\n")[0].trim();
|
||||||
return truncateText(firstLine, 30) || "新对话";
|
return truncateText(firstLine, 30) || "新对话";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function debounce<T extends (...args: any[]) => any>(
|
export function debounce<T extends (...args: any[]) => any>(
|
||||||
fn: T,
|
fn: T,
|
||||||
delay: number,
|
delay: number,
|
||||||
): (...args: Parameters<T>) => ReturnType<T> | undefined {
|
): (...args: Parameters<T>) => ReturnType<T> | undefined {
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
return function (this: any, ...args: Parameters<T>): any {
|
return function (this: any, ...args: Parameters<T>): any {
|
||||||
const context = this;
|
const context = this;
|
||||||
|
|
||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
|
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
return fn.apply(context, args);
|
return fn.apply(context, args);
|
||||||
}, delay);
|
}, delay);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function throttle<T extends (...args: unknown[]) => unknown>(
|
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||||
fn: T,
|
fn: T,
|
||||||
limit: number,
|
limit: number,
|
||||||
): (...args: Parameters<T>) => void {
|
): (...args: Parameters<T>) => void {
|
||||||
let inThrottle = false;
|
let inThrottle = false;
|
||||||
|
|
||||||
return (...args: Parameters<T>) => {
|
return (...args: Parameters<T>) => {
|
||||||
if (!inThrottle) {
|
if (!inThrottle) {
|
||||||
fn(...args);
|
fn(...args);
|
||||||
inThrottle = true;
|
inThrottle = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
inThrottle = false;
|
inThrottle = false;
|
||||||
}, limit);
|
}, limit);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFileIcon(mimeType: string): string {
|
export function getFileIcon(mimeType: string): string {
|
||||||
if (mimeType.startsWith("image/")) return "🖼️";
|
if (mimeType.startsWith("image/")) return "🖼️";
|
||||||
if (mimeType.startsWith("video/")) return "🎬";
|
if (mimeType.startsWith("video/")) return "🎬";
|
||||||
if (mimeType.startsWith("audio/")) return "🎵";
|
if (mimeType.startsWith("audio/")) return "🎵";
|
||||||
if (mimeType.includes("pdf")) return "📄";
|
if (mimeType.includes("pdf")) return "📄";
|
||||||
if (mimeType.includes("word") || mimeType.includes("document")) return "📝";
|
if (mimeType.includes("word") || mimeType.includes("document")) return "📝";
|
||||||
if (mimeType.includes("excel") || mimeType.includes("spreadsheet"))
|
if (mimeType.includes("excel") || mimeType.includes("spreadsheet"))
|
||||||
return "📊";
|
return "📊";
|
||||||
if (mimeType.includes("powerpoint") || mimeType.includes("presentation"))
|
if (mimeType.includes("powerpoint") || mimeType.includes("presentation"))
|
||||||
return "📽️";
|
return "📽️";
|
||||||
if (
|
if (
|
||||||
mimeType.includes("zip") ||
|
mimeType.includes("zip") ||
|
||||||
mimeType.includes("rar") ||
|
mimeType.includes("rar") ||
|
||||||
mimeType.includes("7z")
|
mimeType.includes("7z")
|
||||||
)
|
)
|
||||||
return "📦";
|
return "📦";
|
||||||
return "📎";
|
return "📎";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectCodeLanguage(code: string): string {
|
export function detectCodeLanguage(code: string): string {
|
||||||
if (code.includes("import React") || code.includes("jsx")) return "jsx";
|
if (code.includes("import React") || code.includes("jsx")) return "jsx";
|
||||||
if (code.includes("<template>") || code.includes("defineComponent"))
|
if (code.includes("<template>") || code.includes("defineComponent"))
|
||||||
return "vue";
|
return "vue";
|
||||||
if (code.includes("func ") && code.includes("package ")) return "go";
|
if (code.includes("func ") && code.includes("package ")) return "go";
|
||||||
if (code.includes("def ") && code.includes("import ")) return "python";
|
if (code.includes("def ") && code.includes("import ")) return "python";
|
||||||
if (code.includes("public class") || code.includes("private void"))
|
if (code.includes("public class") || code.includes("private void"))
|
||||||
return "java";
|
return "java";
|
||||||
if (code.includes("fn ") && code.includes("let mut")) return "rust";
|
if (code.includes("fn ") && code.includes("let mut")) return "rust";
|
||||||
if (code.includes("interface ") || code.includes(": string"))
|
if (code.includes("interface ") || code.includes(": string"))
|
||||||
return "typescript";
|
return "typescript";
|
||||||
return "javascript";
|
return "javascript";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,52 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: {
|
||||||
50: '#eff6ff',
|
50: '#eff6ff',
|
||||||
100: '#dbeafe',
|
100: '#dbeafe',
|
||||||
200: '#bfdbfe',
|
200: '#bfdbfe',
|
||||||
300: '#93c5fd',
|
300: '#93c5fd',
|
||||||
400: '#60a5fa',
|
400: '#60a5fa',
|
||||||
500: '#3b82f6',
|
500: '#3b82f6',
|
||||||
600: '#2563eb',
|
600: '#2563eb',
|
||||||
700: '#1d4ed8',
|
700: '#1d4ed8',
|
||||||
800: '#1e40af',
|
800: '#1e40af',
|
||||||
900: '#1e3a8a',
|
900: '#1e3a8a',
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
100: '#1e1e2e',
|
100: '#1e1e2e',
|
||||||
200: '#181825',
|
200: '#181825',
|
||||||
300: '#11111b',
|
300: '#11111b',
|
||||||
400: '#0a0a0f',
|
400: '#0a0a0f',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'fade-in': 'fadeIn 0.3s ease-out',
|
'fade-in': 'fadeIn 0.3s ease-out',
|
||||||
'slide-up': 'slideUp 0.3s ease-out',
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
'pulse-dot': 'pulseDot 1.5s infinite',
|
'pulse-dot': 'pulseDot 1.5s infinite',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
'0%': { opacity: '0' },
|
'0%': { opacity: '0' },
|
||||||
'100%': { opacity: '1' },
|
'100%': { opacity: '1' },
|
||||||
},
|
},
|
||||||
slideUp: {
|
slideUp: {
|
||||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
},
|
},
|
||||||
pulseDot: {
|
pulseDot: {
|
||||||
'0%, 100%': { opacity: '0.4' },
|
'0%, 100%': { opacity: '0.4' },
|
||||||
'50%': { opacity: '1' },
|
'50%': { opacity: '1' },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
}
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
/* Paths */
|
/* Paths */
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
}
|
}
|
||||||
|
|
@ -1,31 +1,31 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
/* Paths */
|
/* Paths */
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"strict": true
|
"strict": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"root":["./src/main.ts","./src/components/icons/index.ts","./src/composables/usekeyboard.ts","./src/services/api.ts","./src/services/mockai.ts","./src/stores/chat.ts","./src/stores/settings.ts","./src/types/chat.ts","./src/utils/helpers.ts","./src/app.vue","./src/components/chat/chatheader.vue","./src/components/chat/chatmain.vue","./src/components/chat/messagelist.vue","./src/components/chat/welcomescreen.vue","./src/components/input/attachmentpreview.vue","./src/components/input/chatinput.vue","./src/components/message/codeblock.vue","./src/components/message/messageactions.vue","./src/components/message/messagebubble.vue","./src/components/message/components/echartscontainernode.vue","./src/components/message/components/loading.vue","./src/components/message/components/thinkingnode.vue","./src/components/modals/conversationsettingsmodal.vue","./src/components/modals/searchmodal.vue","./src/components/modals/settingsmodal.vue","./src/components/modals/shortcutsmodal.vue","./src/components/sidebar/chatsidebar.vue","./src/components/sidebar/conversationitem.vue","./src/components/ui/formselect.vue","./src/components/ui/formslider.vue","./src/components/ui/formswitch.vue"],"version":"5.9.3"}
|
{"root":["./src/main.ts","./src/components/icons/index.ts","./src/composables/useKeyboard.ts","./src/services/api.ts","./src/stores/chat.ts","./src/stores/settings.ts","./src/types/chat.ts","./src/utils/helpers.ts","./src/App.vue","./src/components/chat/ChatHeader.vue","./src/components/chat/ChatMain.vue","./src/components/chat/MessageList.vue","./src/components/chat/WelcomeScreen.vue","./src/components/input/AttachmentPreview.vue","./src/components/input/ChatInput.vue","./src/components/message/CodeBlock.vue","./src/components/message/MessageActions.vue","./src/components/message/MessageBubble.vue","./src/components/message/components/EChartsContainerNode.vue","./src/components/message/components/Loading.vue","./src/components/message/components/ThinkingNode.vue","./src/components/modals/ConversationSettingsModal.vue","./src/components/modals/SearchModal.vue","./src/components/modals/SettingsModal.vue","./src/components/modals/ShortcutsModal.vue","./src/components/sidebar/ChatSidebar.vue","./src/components/sidebar/ConversationItem.vue","./src/components/ui/FormSelect.vue","./src/components/ui/FormSlider.vue","./src/components/ui/FormSwitch.vue"],"version":"5.9.3"}
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
import {
|
import {
|
||||||
defineConfig,
|
defineConfig,
|
||||||
presetAttributify,
|
presetAttributify,
|
||||||
transformerDirectives,
|
transformerDirectives,
|
||||||
transformerVariantGroup,
|
transformerVariantGroup,
|
||||||
presetUno
|
presetUno
|
||||||
} from "unocss";
|
} from "unocss";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
content: {
|
content: {
|
||||||
pipeline: {
|
pipeline: {
|
||||||
include: [
|
include: [
|
||||||
/\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/, // the default
|
/\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/, // the default
|
||||||
"**/src/**/*.{js,ts}" // include js/ts files
|
"**/src/**/*.{js,ts}" // include js/ts files
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
presets: [presetAttributify(), presetUno()],
|
presets: [presetAttributify(), presetUno()],
|
||||||
transformers: [transformerDirectives(), transformerVariantGroup()],
|
transformers: [transformerDirectives(), transformerVariantGroup()],
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
["flex-center", "flex justify-center items-center"],
|
["flex-center", "flex justify-center items-center"],
|
||||||
["full", "w-full h-full"],
|
["full", "w-full h-full"],
|
||||||
[/^(.*)-i$/, ([, prefix]) => `${prefix}!`], // w-full-i -> { width: 100% !important }
|
[/^(.*)-i$/, ([, prefix]) => `${prefix}!`], // w-full-i -> { width: 100% !important }
|
||||||
[/^(.*)-(\d+)p$/, ([, prefix, d]) => `${prefix}-${d}%`], // w-50p -> { width: 50% }
|
[/^(.*)-(\d+)p$/, ([, prefix, d]) => `${prefix}-${d}%`], // w-50p -> { width: 50% }
|
||||||
[/^(.*)-var-(.*)$/, ([, prefix, v]) => `${prefix}-$${v}`] // bg-var-el-color-primary -> { background-color: var(--el-color-primary) }
|
[/^(.*)-var-(.*)$/, ([, prefix, v]) => `${prefix}-$${v}`] // bg-var-el-color-primary -> { background-color: var(--el-color-primary) }
|
||||||
],
|
],
|
||||||
rules: [[/^(.*)-setvar-(.*)$/, ([, prefix, v]) => ({ [`--${prefix}`]: v })]]
|
rules: [[/^(.*)-setvar-(.*)$/, ([, prefix, v]) => ({ [`--${prefix}`]: v })]]
|
||||||
});
|
});
|
||||||
|
|
|
||||||
114
vite.config.ts
114
vite.config.ts
|
|
@ -1,54 +1,60 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import UnoCSS from "unocss/vite";
|
import UnoCSS from "unocss/vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), UnoCSS()],
|
plugins: [vue(), UnoCSS()],
|
||||||
|
|
||||||
// 基础路径
|
// 基础路径
|
||||||
base: "/chat-ui/",
|
base: "/chat-ui/",
|
||||||
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": resolve(__dirname, "src"),
|
"@": resolve(__dirname, "src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
},
|
proxy: {
|
||||||
build: {
|
"/api/chat-ui": {
|
||||||
// 输出目录
|
target: "http://localhost:3000",
|
||||||
outDir: "dist",
|
changeOrigin: true,
|
||||||
|
},
|
||||||
// 静态资源目录
|
},
|
||||||
assetsDir: "assets",
|
},
|
||||||
|
build: {
|
||||||
// 生成 sourcemap(生产环境可关闭)
|
// 输出目录
|
||||||
sourcemap: false,
|
outDir: "dist",
|
||||||
|
|
||||||
// 使用 esbuild 压缩(默认,更快)
|
// 静态资源目录
|
||||||
minify: "esbuild",
|
assetsDir: "assets",
|
||||||
|
|
||||||
// 分包策略
|
// 生成 sourcemap(生产环境可关闭)
|
||||||
rollupOptions: {
|
sourcemap: false,
|
||||||
output: {
|
|
||||||
manualChunks: {
|
// 使用 esbuild 压缩(默认,更快)
|
||||||
"vue-vendor": ["vue", "pinia"],
|
minify: "esbuild",
|
||||||
"ui-vendor": ["lucide-vue-next"],
|
|
||||||
},
|
// 分包策略
|
||||||
chunkFileNames: "assets/js/[name]-[hash].js",
|
rollupOptions: {
|
||||||
entryFileNames: "assets/js/[name]-[hash].js",
|
output: {
|
||||||
assetFileNames: "assets/[ext]/[name]-[hash].[ext]",
|
manualChunks: {
|
||||||
},
|
"vue-vendor": ["vue", "pinia"],
|
||||||
},
|
"ui-vendor": ["lucide-vue-next"],
|
||||||
},
|
},
|
||||||
|
chunkFileNames: "assets/js/[name]-[hash].js",
|
||||||
css: {
|
entryFileNames: "assets/js/[name]-[hash].js",
|
||||||
preprocessorOptions: {
|
assetFileNames: "assets/[ext]/[name]-[hash].[ext]",
|
||||||
scss: {
|
},
|
||||||
additionalData: ``,
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
css: {
|
||||||
});
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
additionalData: ``,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue