feat: 前端搭建

This commit is contained in:
Gsh
2025-06-17 22:37:37 +08:00
parent 4830be6388
commit 0cd795f57a
1228 changed files with 23627 additions and 1 deletions

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
import ChatDefaul from '@/pages/chat/layouts/chatDefaul/index.vue';
import ChatWithId from '@/pages/chat/layouts/chatWithId/index.vue';
const route = useRoute();
const sessionId = computed(() => route.params?.id);
</script>
<template>
<div class="chat-container">
<!-- 默认聊天页面 -->
<ChatDefaul v-if="!sessionId" />
<!-- 带id的聊天页面 -->
<ChatWithId v-else />
</div>
</template>
<style lang="scss" scoped>
.chat-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: calc(100% - 32px);
height: 100%;
padding: 0 16px;
overflow-anchor: none;
}
</style>

View File

@@ -0,0 +1,121 @@
<!-- 默认消息列表页 -->
<script setup lang="ts">
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import FilesSelect from '@/components/FilesSelect/index.vue';
import ModelSelect from '@/components/ModelSelect/index.vue';
import WelecomeText from '@/components/WelecomeText/index.vue';
import { useUserStore } from '@/stores';
import { useFilesStore } from '@/stores/modules/files';
import { useSessionStore } from '@/stores/modules/session';
const userStore = useUserStore();
const sessionStore = useSessionStore();
const filesStore = useFilesStore();
const senderValue = ref('');
const senderRef = ref();
async function handleSend() {
localStorage.setItem('chatContent', senderValue.value);
await sessionStore.createSessionList({
userId: userStore.userInfo?.userId as number,
sessionContent: senderValue.value,
sessionTitle: senderValue.value.slice(0, 10),
remark: senderValue.value.slice(0, 10),
});
}
function handleDeleteCard(_item: FilesCardProps, index: number) {
filesStore.deleteFileByIndex(index);
}
watch(
() => filesStore.filesList.length,
(val) => {
if (val > 0) {
nextTick(() => {
senderRef.value.openHeader();
});
}
else {
nextTick(() => {
senderRef.value.closeHeader();
});
}
},
);
</script>
<template>
<div class="chat-defaul-wrap">
<WelecomeText />
<Sender
ref="senderRef"
v-model="senderValue"
class="chat-defaul-sender"
:auto-size="{
maxRows: 9,
minRows: 3,
}"
variant="updown"
clearable
allow-speech
@submit="handleSend"
>
<template #header>
<div class="sender-header p-12px pt-6px pb-0px">
<Attachments
:items="filesStore.filesList"
:hide-upload="true"
@delete-card="handleDeleteCard"
>
<template #prev-button="{ show, onScrollLeft }">
<div
v-if="show"
class="prev-next-btn left-8px flex-center w-22px h-22px rounded-8px border-1px border-solid border-[rgba(0,0,0,0.08)] c-[rgba(0,0,0,.4)] hover:bg-#f3f4f6 bg-#fff font-size-10px"
@click="onScrollLeft"
>
<el-icon>
<ArrowLeftBold />
</el-icon>
</div>
</template>
<template #next-button="{ show, onScrollRight }">
<div
v-if="show"
class="prev-next-btn right-8px flex-center w-22px h-22px rounded-8px border-1px border-solid border-[rgba(0,0,0,0.08)] c-[rgba(0,0,0,.4)] hover:bg-#f3f4f6 bg-#fff font-size-10px"
@click="onScrollRight"
>
<el-icon>
<ArrowRightBold />
</el-icon>
</div>
</template>
</Attachments>
</div>
</template>
<template #prefix>
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
<FilesSelect />
<ModelSelect />
</div>
</template>
</Sender>
</div>
</template>
<style scoped lang="scss">
.chat-defaul-wrap {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 800px;
min-height: 450px;
.chat-defaul-sender {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,345 @@
<!-- 每个回话对应的聊天内容 -->
<script setup lang="ts">
import type { AnyObject } from 'typescript-api-pro';
import type { Sender } from 'vue-element-plus-x';
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import { useHookFetch } from 'hook-fetch/vue';
import { useRoute } from 'vue-router';
import { send } from '@/api';
import FilesSelect from '@/components/FilesSelect/index.vue';
import ModelSelect from '@/components/ModelSelect/index.vue';
import { useChatStore } from '@/stores/modules/chat';
import { useFilesStore } from '@/stores/modules/files';
import { useModelStore } from '@/stores/modules/model';
import { useUserStore } from '@/stores/modules/user';
type MessageItem = BubbleProps & {
key: number;
role: 'ai' | 'user' | 'system';
avatar: string;
thinkingStatus?: ThinkingStatus;
thinlCollapse?: boolean;
};
const route = useRoute();
const chatStore = useChatStore();
const modelStore = useModelStore();
const filesStore = useFilesStore();
const userStore = useUserStore();
// 用户头像
const avatar = computed(() => {
const userInfo = userStore.userInfo;
return userInfo?.avatar || 'https://avatars.githubusercontent.com/u/76239030?v=4';
});
const inputValue = ref('');
const senderRef = ref<InstanceType<typeof Sender> | null>(null);
const bubbleItems = ref<MessageItem[]>([]);
const bubbleListRef = ref<BubbleListInstance | null>(null);
const { stream, loading: isLoading, cancel } = useHookFetch({
request: send,
onError: (err) => {
console.warn('测试错误拦截', err);
},
});
// 记录进入思考中
let isThinking = false;
watch(
() => route.params?.id,
async (_id_) => {
if (_id_) {
if (_id_ !== 'not_login') {
// 判断的当前会话id是否有聊天记录有缓存则直接赋值展示
if (chatStore.chatMap[`${_id_}`] && chatStore.chatMap[`${_id_}`].length) {
bubbleItems.value = chatStore.chatMap[`${_id_}`] as MessageItem[];
// 滚动到底部
setTimeout(() => {
bubbleListRef.value!.scrollToBottom();
}, 350);
return;
}
// 无缓存则请求聊天记录
await chatStore.requestChatList(`${_id_}`);
// 请求聊天记录后,赋值回显,并滚动到底部
bubbleItems.value = chatStore.chatMap[`${_id_}`] as MessageItem[];
// 滚动到底部
setTimeout(() => {
bubbleListRef.value!.scrollToBottom();
}, 350);
}
// 如果本地有发送内容 ,则直接发送
const v = localStorage.getItem('chatContent');
if (v) {
// 发送消息
console.log('发送消息 v', v);
setTimeout(() => {
startSSE(v);
}, 350);
localStorage.removeItem('chatContent');
}
}
},
{ immediate: true, deep: true },
);
// 封装数据处理逻辑
function handleDataChunk(chunk: AnyObject) {
try {
const reasoningChunk = chunk.choices?.[0].delta.reasoning_content;
if (reasoningChunk) {
// 开始思考链状态
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'thinking';
bubbleItems.value[bubbleItems.value.length - 1].loading = true;
bubbleItems.value[bubbleItems.value.length - 1].thinlCollapse = true;
if (bubbleItems.value.length) {
bubbleItems.value[bubbleItems.value.length - 1].reasoning_content += reasoningChunk;
}
}
// 另一种思考中形式content中有 <think></think> 的格式
// 一开始匹配到 <think> 开始,匹配到 </think> 结束,并处理标签中的内容为思考内容
const parsedChunk = chunk.choices?.[0].delta.content;
if (parsedChunk) {
const thinkStart = parsedChunk.includes('<think>');
const thinkEnd = parsedChunk.includes('</think>');
if (thinkStart) {
isThinking = true;
}
if (thinkEnd) {
isThinking = false;
}
if (isThinking) {
// 开始思考链状态
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'thinking';
bubbleItems.value[bubbleItems.value.length - 1].loading = true;
bubbleItems.value[bubbleItems.value.length - 1].thinlCollapse = true;
if (bubbleItems.value.length) {
bubbleItems.value[bubbleItems.value.length - 1].reasoning_content += parsedChunk
.replace('<think>', '')
.replace('</think>', '');
}
}
else {
// 结束 思考链状态
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'end';
bubbleItems.value[bubbleItems.value.length - 1].loading = false;
if (bubbleItems.value.length) {
bubbleItems.value[bubbleItems.value.length - 1].content += parsedChunk;
}
}
}
}
catch (err) {
// 这里如果使用了中断,会有报错,可以忽略不管
console.error('解析数据时出错:', err);
}
}
// 封装错误处理逻辑
function handleError(err: any) {
console.error('Fetch error:', err);
}
async function startSSE(chatContent: string) {
try {
// 添加用户输入的消息
// console.log('chatContent', chatContent);
// 清空输入框
inputValue.value = '';
addMessage(chatContent, true);
addMessage('', false);
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
bubbleListRef.value?.scrollToBottom();
for await (const chunk of stream({
messages: bubbleItems.value
.filter((item: any) => item.role === 'user')
.map((item: any) => ({
role: item.role,
content: item.content,
})),
sessionId: route.params?.id !== 'not_login' ? String(route.params?.id) : undefined,
userId: userStore.userInfo?.userId,
model: modelStore.currentModelInfo.modelName ?? '',
})) {
handleDataChunk(chunk.result as AnyObject);
}
}
catch (err) {
handleError(err);
}
finally {
console.log('数据接收完毕');
// 停止打字器状态
if (bubbleItems.value.length) {
bubbleItems.value[bubbleItems.value.length - 1].typing = false;
}
}
}
// 中断请求
async function cancelSSE() {
cancel();
// 结束最后一条消息打字状态
if (bubbleItems.value.length) {
bubbleItems.value[bubbleItems.value.length - 1].typing = false;
}
}
// 添加消息 - 维护聊天记录
function addMessage(message: string, isUser: boolean) {
const i = bubbleItems.value.length;
const obj: MessageItem = {
key: i,
avatar: isUser
? avatar.value
: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
avatarSize: '32px',
role: isUser ? 'user' : 'system',
placement: isUser ? 'end' : 'start',
isMarkdown: !isUser,
loading: !isUser,
content: message || '',
reasoning_content: '',
thinkingStatus: 'start',
thinlCollapse: false,
};
bubbleItems.value.push(obj);
}
// 展开收起 事件展示
function handleChange(payload: { value: boolean; status: ThinkingStatus }) {
console.log('value', payload.value, 'status', payload.status);
}
function handleDeleteCard(_item: FilesCardProps, index: number) {
filesStore.deleteFileByIndex(index);
}
watch(
() => filesStore.filesList.length,
(val) => {
if (val > 0) {
nextTick(() => {
senderRef.value?.openHeader();
});
}
else {
nextTick(() => {
senderRef.value?.closeHeader();
});
}
},
);
</script>
<template>
<div class="chat-with-id-container">
<div class="chat-warp">
<BubbleList ref="bubbleListRef" :list="bubbleItems" max-height="calc(100vh - 240px)">
<template #header="{ item }">
<Thinking
v-if="item.reasoning_content" v-model="item.thinlCollapse" :content="item.reasoning_content"
:status="item.thinkingStatus" class="thinking-chain-warp" @change="handleChange"
/>
</template>
</BubbleList>
<Sender
ref="senderRef" v-model="inputValue" class="chat-defaul-sender" :auto-size="{
maxRows: 6,
minRows: 2,
}" variant="updown" clearable allow-speech :loading="isLoading" @submit="startSSE" @cancel="cancelSSE"
>
<template #header>
<div class="sender-header p-12px pt-6px pb-0px">
<Attachments :items="filesStore.filesList" :hide-upload="true" @delete-card="handleDeleteCard">
<template #prev-button="{ show, onScrollLeft }">
<div
v-if="show"
class="prev-next-btn left-8px flex-center w-22px h-22px rounded-8px border-1px border-solid border-[rgba(0,0,0,0.08)] c-[rgba(0,0,0,.4)] hover:bg-#f3f4f6 bg-#fff font-size-10px"
@click="onScrollLeft"
>
<el-icon>
<ArrowLeftBold />
</el-icon>
</div>
</template>
<template #next-button="{ show, onScrollRight }">
<div
v-if="show"
class="prev-next-btn right-8px flex-center w-22px h-22px rounded-8px border-1px border-solid border-[rgba(0,0,0,0.08)] c-[rgba(0,0,0,.4)] hover:bg-#f3f4f6 bg-#fff font-size-10px"
@click="onScrollRight"
>
<el-icon>
<ArrowRightBold />
</el-icon>
</div>
</template>
</Attachments>
</div>
</template>
<template #prefix>
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
<FilesSelect />
<ModelSelect />
</div>
</template>
</Sender>
</div>
</div>
</template>
<style scoped lang="scss">
.chat-with-id-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 800px;
height: 100%;
.chat-warp {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
height: calc(100vh - 60px);
.thinking-chain-warp {
margin-bottom: 12px;
}
}
:deep() {
.el-bubble-list {
padding-top: 24px;
}
.el-bubble {
padding: 0 12px;
padding-bottom: 24px;
}
.el-typewriter {
overflow: hidden;
border-radius: 12px;
}
.markdown-body {
background-color: transparent;
}
}
.chat-defaul-sender {
width: 100%;
margin-bottom: 22px;
}
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { HOME_URL } from '@/config/index.ts';
// 路由跳转
const router = useRouter();
function handleHomePage() {
router.push({ path: HOME_URL });
}
</script>
<template>
<div id="box">
<div id="banner" class="elx-top" />
<div class="elx-bottom">
<div class="elx-text1">
403
</div>
<div class="elx-text2">
对不起您没有权限访问
</div>
<div class="h-20px" />
<el-button type="primary" plain @click="handleHomePage">
返回首页
</el-button>
</div>
</div>
</template>
<style lang="scss" scoped>
#box {
overflow: hidden;
}
#banner {
margin-top: 60px;
background: url("@/assets/images/error/403.png") no-repeat;
background-size: 100%;
}
.elx-top {
width: 600px;
height: 400px;
margin: 0 auto;
}
.elx-bottom {
height: 300px;
margin-top: 20px;
text-align: center;
}
.elx-text1 {
font-size: 46px;
font-weight: bold;
}
.elx-text2 {
padding-top: 30px;
font-family: YouYuan;
font-size: 24px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { HOME_URL } from '@/config/index.ts';
// 路由跳转
const router = useRouter();
function handleHomePage() {
router.push({ path: HOME_URL });
}
</script>
<template>
<div id="box">
<div id="banner" class="elx-top" />
<div class="elx-bottom">
<div class="elx-text1">
404
</div>
<div class="elx-text2">
您想看的页面不存在哟
</div>
<div class="h-20px" />
<el-button type="primary" plain @click="handleHomePage">
返回首页
</el-button>
</div>
</div>
</template>
<style lang="scss" scoped>
#box {
overflow: hidden;
}
#banner {
margin-top: 60px;
background: url("@/assets/images/error/404.png") no-repeat;
background-size: 100%;
}
.elx-top {
width: 600px;
height: 400px;
margin: 0 auto;
}
.elx-bottom {
height: 300px;
margin-top: 20px;
text-align: center;
}
.elx-text1 {
font-size: 46px;
font-weight: bold;
}
.elx-text2 {
padding-top: 30px;
font-family: YouYuan;
font-size: 24px;
font-weight: 600;
}
</style>