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,385 @@
<!-- Aside 侧边栏 -->
<script setup lang="ts">
import type { ConversationItem } from 'vue-element-plus-x/types/Conversations';
import type { ChatSessionVo } from '@/api/session/types';
import { useRoute, useRouter } from 'vue-router';
import { get_session } from '@/api';
import logo from '@/assets/images/logo.png';
import SvgIcon from '@/components/SvgIcon/index.vue';
import Collapse from '@/layouts/components/Header/components/Collapse.vue';
import { useDesignStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session';
const route = useRoute();
const router = useRouter();
const designStore = useDesignStore();
const sessionStore = useSessionStore();
const sessionId = computed(() => route.params?.id);
const conversationsList = computed(() => sessionStore.sessionList);
const loadMoreLoading = computed(() => sessionStore.isLoadingMore);
const active = ref<string | undefined>();
onMounted(async () => {
// 获取会话列表
await sessionStore.requestSessionList();
// 高亮最新会话
if (conversationsList.value.length > 0 && sessionId.value) {
const currentSessionRes = await get_session(`${sessionId.value}`);
// 通过 ID 查询详情,设置当前会话 (因为有分页)
sessionStore.setCurrentSession(currentSessionRes.data);
}
});
watch(
() => sessionStore.currentSession,
(newValue) => {
active.value = newValue ? `${newValue.id}` : undefined;
},
);
// 创建会话
function handleCreatChat() {
// 创建会话, 跳转到默认聊天
sessionStore.createSessionBtn();
}
// 切换会话
function handleChange(item: ConversationItem<ChatSessionVo>) {
sessionStore.setCurrentSession(item);
router.replace({
name: 'chatWithId',
params: {
id: item.id,
},
});
}
// 处理组件触发的加载更多事件
async function handleLoadMore() {
if (!sessionStore.hasMore)
return; // 无更多数据时不加载
await sessionStore.loadMoreSessions();
}
// 右键菜单
function handleMenuCommand(command: string, item: ConversationItem<ChatSessionVo>) {
switch (command) {
case 'delete':
ElMessageBox.confirm('删除后,聊天记录将不可恢复。', '确定删除对话?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
cancelButtonClass: 'el-button--info',
roundButton: true,
autofocus: false,
})
.then(() => {
// 删除会话
sessionStore.deleteSessions([item.id!]);
nextTick(() => {
if (item.id === active.value) {
// 如果删除当前会话 返回到默认页
sessionStore.createSessionBtn();
}
});
})
.catch(() => {
// 取消删除
});
break;
case 'rename':
ElMessageBox.prompt('', '编辑对话名称', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputErrorMessage: '请输入对话名称',
confirmButtonClass: 'el-button--primary',
cancelButtonClass: 'el-button--info',
roundButton: true,
inputValue: item.sessionTitle, // 设置默认值
autofocus: false,
inputValidator: (value) => {
if (!value) {
return false;
}
return true;
},
}).then(({ value }) => {
sessionStore
.updateSession({
id: item.id!,
sessionTitle: value,
sessionContent: item.sessionContent,
})
.then(() => {
ElMessage({
type: 'success',
message: '修改成功',
});
nextTick(() => {
// 如果是当前会话,则更新当前选中会话信息
if (sessionStore.currentSession?.id === item.id) {
sessionStore.setCurrentSession({
...item,
sessionTitle: value,
});
}
});
});
});
break;
default:
break;
}
}
</script>
<template>
<div
class="aside-container"
:class="{
'aside-container-suspended': designStore.isSafeAreaHover,
'aside-container-collapse': designStore.isCollapse,
// 折叠且未激活悬停时添加 no-delay 类
'no-delay': designStore.isCollapse && !designStore.hasActivatedHover,
}"
>
<div class="aside-wrapper">
<div v-if="!designStore.isCollapse" class="aside-header">
<div class="flex items-center gap-8px hover:cursor-pointer" @click="handleCreatChat">
<el-image :src="logo" alt="logo" fit="cover" class="logo-img" />
<span class="logo-text max-w-150px text-overflow">Element Plus X</span>
</div>
<Collapse class="ml-auto" />
</div>
<div class="aside-body">
<div class="creat-chat-btn-wrapper">
<div class="creat-chat-btn" @click="handleCreatChat">
<el-icon class="add-icon">
<Plus />
</el-icon>
<span class="creat-chat-text">新对话</span>
<SvgIcon name="ctrl+k" size="37" />
</div>
</div>
<div class="aside-content">
<div v-if="conversationsList.length > 0" class="conversations-wrap overflow-hidden">
<Conversations
v-model:active="active"
:items="conversationsList"
:label-max-width="200"
:show-tooltip="true"
:tooltip-offset="60"
show-built-in-menu
groupable
row-key="id"
label-key="sessionTitle"
tooltip-placement="right"
:load-more="handleLoadMore"
:load-more-loading="loadMoreLoading"
:items-style="{
marginLeft: '8px',
userSelect: 'none',
borderRadius: '10px',
padding: '8px 12px',
}"
:items-active-style="{
backgroundColor: '#fff',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
color: 'rgba(0, 0, 0, 0.85)',
}"
:items-hover-style="{
backgroundColor: 'rgba(0, 0, 0, 0.04)',
}"
@menu-command="handleMenuCommand"
@change="handleChange"
/>
</div>
<el-empty v-else class="h-full flex-center" description="暂无对话记录" />
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
// 基础样式
.aside-container {
position: absolute;
top: 0;
left: 0;
z-index: 11;
width: var(--sidebar-default-width);
height: 100%;
pointer-events: auto;
background-color: var(--sidebar-background-color);
border-right: 0.5px solid var(--s-color-border-tertiary, rgb(0 0 0 / 8%));
.aside-wrapper {
display: flex;
flex-direction: column;
height: 100%;
// 侧边栏头部样式
.aside-header {
display: flex;
align-items: center;
height: 36px;
margin: 10px 12px 0;
.logo-img {
box-sizing: border-box;
width: 36px;
height: 36px;
padding: 4px;
overflow: hidden;
background-color: #ffffff;
border-radius: 50%;
img {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: rgb(0 0 0 / 85%);
transform: skewX(-2deg);
}
}
// 侧边栏内容样式
.aside-body {
.creat-chat-btn-wrapper {
padding: 0 12px;
.creat-chat-btn {
display: flex;
gap: 6px;
align-items: center;
padding: 8px 6px;
margin-top: 16px;
margin-bottom: 6px;
color: #0057ff;
cursor: pointer;
user-select: none;
background-color: rgb(0 87 255 / 6%);
border: 1px solid rgb(0 102 255 / 15%);
border-radius: 12px;
&:hover {
background-color: rgb(0 87 255 / 12%);
}
.creat-chat-text {
font-size: 14px;
font-weight: 700;
line-height: 22px;
}
.add-icon {
width: 24px;
height: 24px;
font-size: 16px;
}
.svg-icon {
height: 24px;
margin-left: auto;
color: rgb(0 87 255 / 30%);
}
}
}
.aside-content {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
min-height: 0;
// 会话列表高度-基础样式
.conversations-wrap {
height: calc(100vh - 110px);
.label {
display: flex;
align-items: center;
height: 100%;
}
}
}
}
}
}
// 折叠样式
.aside-container-collapse {
position: absolute;
top: 54px;
z-index: 22;
height: auto;
max-height: calc(100% - 110px);
padding-bottom: 12px;
overflow: hidden;
/* 禁用悬停事件 */
pointer-events: none;
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 15px;
box-shadow:
0 10px 20px 0 rgb(0 0 0 / 10%),
0 0 1px 0 rgb(0 0 0 / 15%);
opacity: 0;
transition: opacity 0.3s ease 0.3s, transform 0.3s ease 0.3s;
// 指定样式过渡
// 向左偏移一个宽度
transform: translateX(-100%);
/* 新增:未激活悬停时覆盖延迟 */
&.no-delay {
transition-delay: 0s, 0s;
}
}
// 悬停样式
.aside-container-collapse:hover,
.aside-container-collapse.aside-container-suspended {
height: auto;
max-height: calc(100% - 110px);
padding-bottom: 12px;
overflow: hidden;
pointer-events: auto;
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 15px;
box-shadow:
0 10px 20px 0 rgb(0 0 0 / 10%),
0 0 1px 0 rgb(0 0 0 / 15%);
// 直接在这里写悬停时的样式(与 aside-container-suspended 一致)
opacity: 1;
transition: opacity 0.3s ease 0s, transform 0.3s ease 0s;
// 过渡动画沿用原有设置
transform: translateX(15px);
// 会话列表高度-悬停样式
.conversations-wrap {
height: calc(100vh - 155px) !important;
}
}
// 样式穿透
:deep() {
// 会话列表背景色
.conversations-list {
background-color: transparent !important;
}
// 群组标题样式 和 侧边栏菜单背景色一致
.conversation-group-title {
padding-left: 12px !important;
background-color: var(--sidebar-background-color) !important;
}
}
</style>

View File

@@ -0,0 +1,8 @@
<!-- DesignConfig -->
<script setup lang="ts"></script>
<template>
<div>配置页面</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,140 @@
<!-- 头像 -->
<script setup lang="ts">
import Popover from '@/components/Popover/index.vue';
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useUserStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session';
const userStore = useUserStore();
const sessionStore = useSessionStore();
const src = computed(
() => userStore.userInfo?.avatar ?? 'https://avatars.githubusercontent.com/u/76239030',
);
/* 弹出面板 开始 */
const popoverStyle = ref({
width: '200px',
padding: '4px',
height: 'fit-content',
});
const popoverRef = ref();
// 弹出面板内容
const popoverList = ref([
{
key: '1',
title: '收藏夹',
icon: 'book-mark-fill',
},
{
key: '2',
title: '设置',
icon: 'settings-4-fill',
},
{
key: '3',
divider: true,
},
{
key: '4',
title: '退出登录',
icon: 'logout-box-r-line',
},
]);
// 点击
function handleClick(item: any) {
switch (item.key) {
case '1':
ElMessage.warning('暂未开放');
console.log('点击了收藏夹');
break;
case '2':
ElMessage.warning('暂未开放');
console.log('点击了设置');
break;
case '4':
popoverRef.value?.hide?.();
ElMessageBox.confirm('退出登录不会丢失任何数据,你仍可以登录此账号。', '确认退出登录?', {
confirmButtonText: '确认退出',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
cancelButtonClass: 'el-button--info',
roundButton: true,
autofocus: false,
})
.then(async () => {
// 在这里执行退出方法
await userStore.logout();
// 清空回话列表并回到默认页
await sessionStore.requestSessionList(1, true);
await sessionStore.createSessionBtn();
ElMessage({
type: 'success',
message: '退出成功',
});
})
.catch(() => {
// ElMessage({
// type: 'info',
// message: '取消',
// });
});
break;
default:
break;
}
}
/* 弹出面板 结束 */
</script>
<template>
<div class="avatar-container">
<Popover
ref="popoverRef"
placement="bottom-end"
trigger="clickTarget"
:trigger-style="{ cursor: 'pointer' }"
popover-class="popover-content"
:popover-style="popoverStyle"
>
<!-- 触发元素插槽 -->
<template #trigger>
<el-avatar :src="src" :size="28" fit="fit" shape="circle" />
</template>
<div class="popover-content-box shadow-lg">
<div v-for="item in popoverList" :key="item.key" class="popover-content-box-items h-full">
<div
v-if="!item.divider"
class="popover-content-box-item flex items-center h-full gap-8px p-8px pl-10px pr-12px rounded-lg hover:cursor-pointer hover:bg-[rgba(0,0,0,.04)]"
@click="handleClick(item)"
>
<SvgIcon :name="item.icon!" size="16" class-name="flex-none" />
<div class="popover-content-box-item-text font-size-14px text-overflow max-h-120px">
{{ item.title }}
</div>
</div>
<div v-if="item.divider" class="divder h-1px bg-gray-200 my-4px" />
</div>
</div>
</Popover>
</div>
</template>
<style scoped lang="scss">
.popover-content {
width: 520px;
height: 520px;
}
.popover-content-box {
padding: 8px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
}
</style>

View File

@@ -0,0 +1,39 @@
<!-- 侧边栏折叠按钮 -->
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon/index.vue';
import { SIDE_BAR_WIDTH } from '@/config/index';
import { useCollapseToggle } from '@/hooks/useCollapseToggle';
import { useDesignStore } from '@/stores';
const { changeCollapse } = useCollapseToggle();
const designStore = useDesignStore();
function handleChangeCollapse() {
changeCollapse();
// 每次切换折叠状态,重置安全区状态
designStore.isSafeAreaHover = false;
// 重置首次激活悬停状态
designStore.hasActivatedHover = false;
if (!designStore.isCollapse) {
document.documentElement.style.setProperty(
`--sidebar-left-container-default-width`,
`${SIDE_BAR_WIDTH}px`,
);
}
else {
document.documentElement.style.setProperty(`--sidebar-left-container-default-width`, ``);
}
}
</script>
<template>
<div class="collapse-container btn-icon-btn" @click="handleChangeCollapse">
<SvgIcon v-if="!designStore.isCollapse" name="ms-left-panel-close-outline" size="24" />
<SvgIcon v-if="designStore.isCollapse" name="ms-left-panel-open-outline" size="24" />
</div>
</template>
<style lang="scss" scoped>
// .collapse-container {
// }
</style>

View File

@@ -0,0 +1,45 @@
<!-- 添加新会话按钮 -->
<script setup lang="ts">
import { useSessionStore } from '@/stores/modules/session';
const sessionStore = useSessionStore();
/* 创建会话 开始 */
function handleCreatChat() {
if (!sessionStore.currentSession)
return;
// 创建会话, 跳转到默认聊天
sessionStore.createSessionBtn();
}
/* 创建会话 结束 */
</script>
<template>
<div
class="create-chat-container flex-center flex-none p-6px pl-8px pr-8px c-#0057ff b-#0057ff b-rounded-12px border-1px hover:bg-#0057ff hover:c-#fff hover:b-#fff hover:cursor-pointer border-solid select-none"
:class="{
'is-disabled': !sessionStore.currentSession,
}"
@click="handleCreatChat"
>
<el-icon size="12" class="flex-center flex-none w-14px h-14px">
<Plus />
</el-icon>
<span class="ml-4px font-size-14px font-700">新对话</span>
</div>
</template>
<style scoped lang="scss">
.is-disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
color: #0057ff;
cursor: not-allowed;
background-color: transparent;
border-color: #0057ff;
border-style: solid;
transition: none;
}
}
</style>

View File

@@ -0,0 +1,29 @@
<!-- LoginBtn 登录按钮 -->
<script setup lang="ts">
import LoginDialog from '@/components/LoginDialog/index.vue';
import { useUserStore } from '@/stores';
const userStore = useUserStore();
const isLoginDialogVisible = computed(() => userStore.isLoginDialogVisible);
// 点击登录按钮时调用Store方法打开弹框
function handleClickLogin() {
userStore.openLoginDialog();
}
</script>
<template>
<div class="login-btn-wrapper">
<div
class="login-btn bg-#191c1f c-#fff font-size-14px rounded-8px flex-center text-overflow p-10px pl-12px pr-12px min-w-49px h-16px cursor-pointer hover:bg-#232629 select-none"
@click="handleClickLogin"
>
登录
</div>
<!-- 登录弹框 -->
<LoginDialog v-model:visible="isLoginDialogVisible" />
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,87 @@
<!-- 标题编辑 -->
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useSessionStore } from '@/stores/modules/session';
const sessionStore = useSessionStore();
const currentSession = computed(() => sessionStore.currentSession);
function handleClickTitle() {
ElMessageBox.prompt('', '编辑对话名称', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputErrorMessage: '请输入对话名称',
confirmButtonClass: 'el-button--primary',
cancelButtonClass: 'el-button--info',
roundButton: true,
inputValue: currentSession.value?.sessionTitle,
inputValidator: (value) => {
if (!value) {
return false;
}
return true;
},
})
.then(({ value }) => {
sessionStore
.updateSession({
id: currentSession.value!.id,
sessionTitle: value,
sessionContent: currentSession.value!.sessionContent,
})
.then(() => {
ElMessage({
type: 'success',
message: '修改成功',
});
nextTick(() => {
// 如果是当前会话,则更新当前选中会话信息
sessionStore.setCurrentSession({
...currentSession.value,
sessionTitle: value,
});
});
});
})
.catch(() => {
// ElMessage({
// type: 'info',
// message: '取消修改',
// });
});
}
</script>
<template>
<div v-if="currentSession" class="w-full h-full flex flex-col justify-center">
<div class="box-border mr-20px">
<div
class="title-editing-container p-4px w-fit max-w-full flex items-center justify-start cursor-pointer select-none hover:bg-[rgba(0,0,0,.04)] cursor-pointer rounded-md font-size-14px"
@click="handleClickTitle"
>
<div class="text-overflow select-none pr-8px">
{{ currentSession.sessionTitle }}
</div>
<SvgIcon name="draft-line" size="14" class="flex-none c-gray-500" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.title-editing-container {
transition: all 0.3s ease;
&:hover {
.svg-icon {
display: block;
opacity: 1;
}
}
.svg-icon {
display: none;
opacity: 0.5;
transition: all 0.3s ease;
}
}
</style>

View File

@@ -0,0 +1,100 @@
<!-- Header 头部 -->
<script setup lang="ts">
import { onKeyStroke } from '@vueuse/core';
import { SIDE_BAR_WIDTH } from '@/config/index';
import { useDesignStore, useUserStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session';
import Avatar from './components/Avatar.vue';
import Collapse from './components/Collapse.vue';
import CreateChat from './components/CreateChat.vue';
import LoginBtn from './components/LoginBtn.vue';
import TitleEditing from './components/TitleEditing.vue';
const userStore = useUserStore();
const designStore = useDesignStore();
const sessionStore = useSessionStore();
const currentSession = computed(() => sessionStore.currentSession);
onMounted(() => {
// 全局设置侧边栏默认宽度 (这个是不变的,一开始就设置)
document.documentElement.style.setProperty(`--sidebar-default-width`, `${SIDE_BAR_WIDTH}px`);
if (designStore.isCollapse) {
document.documentElement.style.setProperty(`--sidebar-left-container-default-width`, ``);
}
else {
document.documentElement.style.setProperty(
`--sidebar-left-container-default-width`,
`${SIDE_BAR_WIDTH}px`,
);
}
});
// 定义 Ctrl+K 的处理函数
function handleCtrlK(event: KeyboardEvent) {
event.preventDefault(); // 防止默认行为
sessionStore.createSessionBtn();
}
// 设置全局的键盘按键监听
onKeyStroke(event => event.ctrlKey && event.key.toLowerCase() === 'k', handleCtrlK, {
passive: false,
});
</script>
<template>
<div class="header-container">
<div class="header-box relative z-10 top-0 left-0 right-0">
<div class="absolute left-0 right-0 top-0 bottom-0 flex items-center flex-row">
<div
class="overflow-hidden flex h-full items-center flex-row flex-1 w-fit flex-shrink-0 min-w-0"
>
<div class="w-full flex items-center flex-row">
<!-- 左边 -->
<div
v-if="designStore.isCollapse"
class="left-box flex h-full items-center pl-20px gap-12px flex-shrink-0 flex-row"
>
<Collapse />
<CreateChat />
<div v-if="currentSession" class="w-0.5px h-30px bg-[rgba(217,217,217)]" />
</div>
<!-- 中间 -->
<div class="middle-box flex-1 min-w-0 ml-12px">
<TitleEditing />
</div>
</div>
</div>
<!-- 右边 -->
<div class="right-box flex h-full items-center pr-20px flex-shrink-0 mr-auto flex-row">
<Avatar v-show="userStore.token" />
<LoginBtn v-show="!userStore.token" />
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.header-container {
display: flex;
flex-shrink: 0;
flex-direction: column;
width: 100%;
height: fit-content;
.header-box {
width: 100%;
width: calc(
100% - var(--sidebar-left-container-default-width, 0px) - var(
--sidebar-right-container-default-width,
0px
)
);
height: var(--header-container-default-heigth);
margin: 0 var(--sidebar-right-container-default-width, 0) 0
var(--sidebar-left-container-default-width, 0);
}
}
</style>

View File

@@ -0,0 +1,8 @@
<!-- Logo -->
<script setup lang="ts"></script>
<template>
<div>Logo</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,89 @@
<!-- Main -->
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { useDesignStore } from '@/stores';
import { useKeepAliveStore } from '@/stores/modules/keepAlive';
const designStore = useDesignStore();
const keepAliveStore = useKeepAliveStore();
const useroute = useRoute();
const transitionName = computed(() => {
if (useroute.meta.isDefaultChat) {
return 'slide';
}
else {
return designStore.pageAnimateType;
}
});
// 刷新当前路由页面缓存方法
const isRouterShow = ref(true);
const refreshMainPage = (val: boolean) => (isRouterShow.value = val);
provide('refresh', refreshMainPage);
</script>
<template>
<el-main
class="layout-main"
:class="{ 'layout-main-overfow-hidden': useroute.meta.isDefaultChat }"
>
<router-view v-slot="{ Component, route }">
<transition :name="transitionName" mode="out-in" appear>
<keep-alive :max="10" :include="keepAliveStore.keepAliveName">
<component :is="Component" v-if="isRouterShow" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
</el-main>
</template>
<style scoped lang="scss">
.layout-main-overfow-hidden {
overflow: hidden;
}
/* 默认聊天页面:上下滑动动画 */
.slide-enter-from {
margin-top: 200px;
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s; /* 缓出动画 */
}
.slide-enter-to {
margin-top: 0;
opacity: 1;
}
.slide-leave-from {
margin-top: 0;
opacity: 1;
}
.slide-leave-to {
margin-top: 200px;
opacity: 0;
}
/* 带id聊天页面中间缩放动画 */
// .zoom-fade-enter-from {
// transform: scale(0.9); /* 进入前:缩小隐藏 */
// opacity: 0;
// }
// .zoom-fade-enter-active,
// .zoom-fade-leave-active {
// transition: all 0.3s; /* 缓入动画 */
// }
// .zoom-fade-enter-to {
// transform: scale(1); /* 进入后:正常大小 */
// opacity: 1;
// }
// .zoom-fade-leave-from {
// transform: scale(1); /* 离开前:正常大小 */
// opacity: 1;
// }
// .zoom-fade-leave-to {
// transform: scale(0.9); /* 离开后:缩小隐藏 */
// opacity: 0;
// }
</style>