feat:完成简易的的前端和后端api

This commit is contained in:
2024-10-17 09:29:28 +08:00
parent aab5043fed
commit 848484682a
67 changed files with 3024 additions and 2053 deletions

3
frontend/env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare module "*.vue"

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1861
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force"
},
"dependencies": {
"axios": "^1.7.7",
"element-plus": "^2.8.5",
"marked": "^14.1.3",
"vue": "^3.5.11",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@tsconfig/node20": "^20.1.4",
"@types/node": "^20.16.11",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/tsconfig": "^0.5.1",
"npm-run-all2": "^6.2.3",
"typescript": "~5.5.4",
"vite": "^5.4.8",
"vue-tsc": "^2.1.6"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

34
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<div class="chat-container">
<RouterView />
</div>
</template>
<style scoped>
/* 聊天框容器样式 */
.chat-container {
display: flex;
justify-content: center;
align-items: center;
height: 90vh; /* 高度占据视口的 90% */
width: 90vw; /* 宽度占据视口的 90% */
margin: auto;
background-color: #f5f5f5;
border-radius: 10px;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
overflow: hidden; /* 防止内容溢出 */
}
/* 聊天框内部内容样式 */
.chat-content {
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
overflow-y: auto; /* 允许垂直滚动 */
}
</style>

View File

@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}

15
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,15 @@
import './assets/main.css'
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
// import ElementPlus from 'element-plus';
// import 'element-plus/dist/index.css';
const app = createApp(App);
app.use(router);
// app.use(ElementPlus);
app.mount('#app');

View File

@@ -0,0 +1,29 @@
import { createRouter, createWebHistory } from 'vue-router';
import ChatPage from '../views/ChatPage.vue';
import LoginPage from '../views/LoginPage.vue';
const routes = [
{ path: '/', redirect: '/chat' },
{ path: '/login', component: LoginPage },
{ path: '/chat', component: ChatPage }, // 聊天页面
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// 路由守卫,确保用户登录后才能访问聊天页面
router.beforeEach((to, from, next) => {
const isAuthenticated = document.cookie.includes('auth_token'); // 假设后端返回的是这个Cookie
if (to.path === '/chat' && !isAuthenticated) {
next('/login'); // 未登录时跳转到登录页面
} else {
next(); // 允许进入
}
});
export default router;

View File

@@ -0,0 +1,340 @@
<template>
<div class="chat-app">
<!-- 左侧对话列表 -->
<aside :class="['conversation-list', { 'collapsed': isCollapsed }]">
<div v-if="!isCollapsed" v-for="(conversation, index) in conversations" :key="index"
class="conversation-item" @click="selectConversation(index)"
:class="{ 'active': index === selectedConversation }">
<span>对话 {{ index + 1 }}</span>
<button @click.stop="deleteConversation(index)" class="delete-btn">删除</button>
</div>
<button v-if="!isCollapsed" @click="newConversation" class="new-conversation-btn">新建对话</button>
<button @click="toggleCollapse" class="collapse-btn">{{ isCollapsed ? '>' : '折叠' }}</button>
</aside>
<!-- 聊天内容区域 -->
<section class="chat-container">
<!-- 模型选择 -->
<div class="model-selection">
<select v-model="selectedModel" @change="fetchMessages" class="model-dropdown">
<option v-for="model in models" :key="model.id" :value="model.id">
{{ model.name }}
</option>
</select>
</div>
<!-- 聊天窗口 -->
<div ref="chatWindow" class="chat-window">
<div v-for="(message, index) in conversations[selectedConversation].messages" :key="index"
:class="['message', message.sender]">
<div class="message-bubble" v-html="renderMarkdown(message.text)"></div>
</div>
</div>
<!-- 输入框 -->
<div class="input-area">
<input v-model="userInput" @keyup.enter="sendMessage" placeholder="请输入您的消息..." class="chat-input" />
<button @click="sendMessage" class="send-btn">发送</button>
<button @click="clearHistory" class="clear-btn">清除记录</button>
</div>
</section>
</div>
</template>
<script>
import axios from 'axios';
import { marked } from 'marked';
export default {
data() {
return {
userInput: '',
conversations: JSON.parse(localStorage.getItem('conversations')) || [
{
messages: [{ sender: 'bot', text: '你好!请选择一个模型开始聊天。' }],
},
],
selectedConversation: 0,
models: [],
selectedModel: '',
isCollapsed: false, // 控制菜单折叠的状态
conversationLimit: 6, // 对话数量上限
};
},
created() {
this.fetchModels();
},
methods: {
async fetchModels() {
try {
const response = await axios.get('/api/chat/models');
this.models = response.data.models;
this.selectedModel = this.models[0].id;
} catch (error) {
console.error('获取模型列表时出错:', error);
this.conversations[this.selectedConversation].messages.push({ sender: 'bot', text: '获取模型列表失败,请稍后再试。' });
}
},
async sendMessage() {
if (this.userInput.trim() === '' || !this.selectedModel) return;
const currentMessages = this.conversations[this.selectedConversation].messages;
currentMessages.push({ sender: 'user', text: this.userInput });
currentMessages.push({ sender: 'bot', text: '正在思考...' });
const userMessage = this.userInput;
this.userInput = '';
try {
const response = await axios.post('/api/chat', {
message: userMessage,
model: this.selectedModel,
});
currentMessages.pop();
currentMessages.push({ sender: 'bot', text: response.data.message });
this.saveConversations();
this.scrollToBottom();
} catch (error) {
console.error('发送消息出错:', error);
currentMessages.push({ sender: 'bot', text: '抱歉!出现了错误。' });
}
},
renderMarkdown(text) {
return marked(text);
},
scrollToBottom() {
const chatWindow = this.$refs.chatWindow;
chatWindow.scrollTop = chatWindow.scrollHeight;
},
saveConversations() {
localStorage.setItem('conversations', JSON.stringify(this.conversations));
},
loadConversation() {
this.scrollToBottom();
},
clearHistory() {
this.conversations[this.selectedConversation].messages = [];
this.saveConversations();
},
newConversation() {
if (this.conversations.length >= this.conversationLimit) {
alert(`对话数量已达到上限(${this.conversationLimit}条)。`);
return;
}
this.conversations.push({
messages: [{ sender: 'bot', text: '新对话已创建,请选择一个模型开始聊天。' }],
});
this.selectedConversation = this.conversations.length - 1;
this.saveConversations();
},
deleteConversation(index) {
if (this.conversations.length === 1) {
alert('至少需要保留一个对话。');
return;
}
if (confirm('确定要删除这个对话吗?')) {
this.conversations.splice(index, 1);
if (this.selectedConversation === index) {
this.selectedConversation = 0;
}
this.saveConversations();
}
},
selectConversation(index) {
this.selectedConversation = index;
this.scrollToBottom();
},
toggleCollapse() {
this.isCollapsed = !this.isCollapsed;
}
},
mounted() {
this.scrollToBottom();
},
updated() {
this.scrollToBottom();
},
};
</script>
<style scoped>
/* 全局容器样式 */
.chat-app {
display: flex;
height: 90vh;
width: 90vw;
margin: auto;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 左侧对话列表 */
.conversation-list {
width: 20%;
background-color: #f9f9f9;
border-right: 1px solid #ddd;
padding: 20px;
display: flex;
flex-direction: column;
transition: width 0.3s;
}
.conversation-list.collapsed {
width: 50px;
padding: 10px;
}
.conversation-item {
display: flex;
justify-content: space-between;
padding: 10px;
margin-bottom: 10px;
border-radius: 5px;
background-color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
cursor: pointer;
}
.conversation-item.active {
background-color: #007bff;
color: #fff;
}
.new-conversation-btn {
background-color: #28a745;
color: white;
padding: 10px;
border: none;
border-radius: 5px;
cursor: pointer;
margin-top: auto;
}
.new-conversation-btn:hover {
background-color: #218838;
}
.delete-btn {
background-color: #dc3545;
border: none;
color: white;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
}
.delete-btn:hover {
background-color: #c82333;
}
.collapse-btn {
margin-top: auto;
padding: 5px 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
margin-top: 10px;
}
.collapse-btn:hover {
background-color: #0056b3;
}
/* 右侧聊天窗口 */
.chat-container {
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.model-selection {
padding: 10px;
background-color: #f1f1f1;
border-bottom: 1px solid #ddd;
}
.model-dropdown {
width: 100%;
padding: 5px;
font-size: 14px;
border-radius: 5px;
border: 1px solid #ddd;
outline: none;
}
.chat-window {
flex-grow: 1;
padding: 20px;
overflow-y: auto;
background-color: #fff;
border-bottom: 1px solid #ddd;
}
.message {
display: flex;
margin-bottom: 10px;
}
.message.user {
justify-content: flex-end;
}
.message.bot {
justify-content: flex-start;
}
.message-bubble {
max-width: 60%;
padding: 15px;
border-radius: 10px;
font-size: 14px;
}
.message.user .message-bubble {
background-color: #007bff;
color: white;
border-bottom-right-radius: 0;
}
.message.bot .message-bubble {
background-color: #f1f1f1;
color: black;
border-bottom-left-radius: 0;
}
.input-area {
display: flex;
padding: 15px;
background-color: #f1f1f1;
border-top: 1px solid #ddd;
}
.chat-input {
flex-grow: 1;
padding: 10px;
font-size: 14px;
border: 1px solid #ddd;
border-radius: 20px;
outline: none;
}
.send-btn,
.clear-btn {
background-color: #007bff;
color: white;
padding: 10px 15px;
margin-left: 10px;
border: none;
border-radius: 20px;
cursor: pointer;
}
.send-btn:hover,
.clear-btn:hover {
background-color: #0056b3;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div class="login-container">
<div class="login-box">
<h2>登录</h2>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="userinfo">UID或邮箱</label>
<input
type="text"
id="userinfo"
v-model="loginForm.userinfo"
placeholder="请输入用户名"
class="form-input"
required
/>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
type="password"
id="password"
v-model="loginForm.password"
placeholder="请输入密码"
class="form-input"
required
/>
</div>
<div class="form-group">
<button type="submit" class="login-btn">登录</button>
</div>
</form>
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
</div>
</div>
</template>
<script>
import axios from 'axios';
export default {
data() {
return {
loginForm: {
userinfo: '',
password: '',
},
errorMessage: '',
};
},
methods: {
async handleLogin() {
try {
const response = await axios.post('/api/user/login', this.loginForm);
if (response.status === 200) {
// 登录成功,跳转到聊天页面
this.$router.push('/chat');
}
} catch (error) {
// 登录失败,显示错误信息
this.errorMessage = '登录失败,请检查用户名或密码';
}
},
},
};
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f5f5f5;
}
.login-box {
background-color: #fff;
padding: 40px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 350px;
text-align: center;
}
h2 {
margin-bottom: 20px;
color: #333;
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #333;
}
.form-input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 5px rgba(0, 123, 255, 0.5);
}
.login-btn {
width: 100%;
padding: 10px;
background-color: #007bff;
color: #fff;
border: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
}
.login-btn:hover {
background-color: #0056b3;
}
.error-message {
color: #ff4d4f;
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})